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
+35
View File
@@ -0,0 +1,35 @@
# n8n
This integration connects your Botpress bot to n8n, letting you trigger workflows from your bot and receive results back as events.
## Prerequisites
- An n8n instance
- An n8n API key — found under **Settings → n8n API**. Generating an API key requires a paid n8n plan and is not available on the free trial.
## Receiving data back from n8n
To send data back to Botpress after an n8n workflow finishes, add an **HTTP Request** node at the end of your workflow and configure it to POST to your integration's webhook URL.
You can find the webhook URL in your Botpress dashboard under **Integrations → n8n → Webhook URL**.
**Trigger Workflow** automatically sends `conversationId` to n8n, so `{{ $json.body.conversationId }}` is available in your workflow without any extra configuration.
Set the action's **Conversation ID** input to `{{ event.conversationId }}`.
Set the request body to:
```json
{
"conversationId": "{{ $json.body.conversationId }}",
"workflowId": "{{ $workflow.id }}",
"workflowName": "{{ $workflow.name }}",
"data": {
"anyKey": "anyValue"
}
}
```
## Limitations
- **Trigger Workflow** only works on workflows that have a **Webhook** node (`n8n-nodes-base.webhook`) with a path configured.
- **List Workflows** returns a paginated response. Use the `limit` and `cursor` inputs to page through results for many workflows.
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>n8n</title><path clip-rule="evenodd" d="M24 8.4c0 1.325-1.102 2.4-2.462 2.4-1.146 0-2.11-.765-2.384-1.8h-3.436c-.602 0-1.115.424-1.214 1.003l-.101.592a2.38 2.38 0 01-.8 1.405c.412.354.704.844.8 1.405l.1.592A1.222 1.222 0 0015.719 15h.975c.273-1.035 1.237-1.8 2.384-1.8 1.36 0 2.461 1.075 2.461 2.4S20.436 18 19.078 18c-1.147 0-2.11-.765-2.384-1.8h-.975c-1.204 0-2.23-.848-2.428-2.005l-.101-.592a1.222 1.222 0 00-1.214-1.003H10.97c-.308.984-1.246 1.7-2.356 1.7-1.11 0-2.048-.716-2.355-1.7H4.817c-.308.984-1.246 1.7-2.355 1.7C1.102 14.3 0 13.225 0 11.9s1.102-2.4 2.462-2.4c1.183 0 2.172.815 2.408 1.9h1.337c.236-1.085 1.225-1.9 2.408-1.9 1.184 0 2.172.815 2.408 1.9h.952c.601 0 1.115-.424 1.213-1.003l.102-.592c.198-1.157 1.225-2.005 2.428-2.005h3.436c.274-1.035 1.238-1.8 2.384-1.8C22.898 6 24 7.075 24 8.4zm-1.23 0c0 .663-.552 1.2-1.232 1.2-.68 0-1.23-.537-1.23-1.2 0-.663.55-1.2 1.23-1.2.68 0 1.231.537 1.231 1.2zM2.461 13.1c.68 0 1.23-.537 1.23-1.2 0-.663-.55-1.2-1.23-1.2-.68 0-1.231.537-1.231 1.2 0 .663.55 1.2 1.23 1.2zm6.153 0c.68 0 1.231-.537 1.231-1.2 0-.663-.55-1.2-1.23-1.2-.68 0-1.231.537-1.231 1.2 0 .663.55 1.2 1.23 1.2zm10.462 3.7c.68 0 1.23-.537 1.23-1.2 0-.663-.55-1.2-1.23-1.2-.68 0-1.23.537-1.23 1.2 0 .663.55 1.2 1.23 1.2z" fill="#EA4B71" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+133
View File
@@ -0,0 +1,133 @@
import { z, IntegrationDefinition } from '@botpress/sdk'
export default new IntegrationDefinition({
name: 'n8n',
title: 'n8n',
description: 'This integration allows you to interact with n8n workflows.',
version: '0.1.1',
readme: 'hub.md',
icon: 'icon.svg',
configuration: {
schema: z.object({
baseUrl: z
.string()
.url()
.title('Base URL')
.describe('The base URL of your n8n instance, for example https://example.app.n8n.cloud'),
accessKey: z
.string()
.min(1)
.title('Access Key')
.describe('Your n8n API key. Found in n8n under Settings → n8n API.'),
}),
},
actions: {
listWorkflows: {
title: 'List Workflows',
description: 'Retrieves workflows from n8n.',
input: {
schema: z.object({
active: z.boolean().optional().title('Active').describe('Filter by workflow active state'),
name: z.string().optional().title('Name').describe('Filter by workflow name'),
tags: z.string().optional().title('Tags').describe('Comma-separated tag filter'),
projectId: z.string().optional().title('Project ID').describe('Filter by project ID'),
excludePinnedData: z
.boolean()
.optional()
.default(true)
.title('Exclude Pinned Data')
.describe('Exclude pinned data from the response'),
limit: z
.number()
.int()
.max(250)
.optional()
.title('Limit')
.describe('Maximum number of workflows to return (1-250)'),
cursor: z.string().optional().title('Cursor').describe('Pagination cursor from a previous request'),
}),
},
output: {
schema: z.object({
data: z.array(z.any()).title('Workflows').describe('List of workflows returned by n8n'),
nextCursor: z
.string()
.optional()
.title('Next Cursor')
.describe('Cursor for fetching the next page of results'),
}),
},
},
getWorkflow: {
title: 'Get Workflow',
description: 'Retrieves a single workflow from n8n by ID.',
input: {
schema: z.object({
workflowId: z.string().min(1).title('Workflow ID').describe('The workflow ID'),
excludePinnedData: z
.boolean()
.optional()
.default(true)
.title('Exclude Pinned Data')
.describe('Exclude pinned data from the response'),
}),
},
output: {
schema: z.object({
workflow: z.any().title('Workflow').describe('The full workflow object returned by n8n'),
}),
},
},
triggerWorkflow: {
title: 'Trigger Workflow',
description: 'Resolves an n8n workflow webhook and posts data to it.',
input: {
schema: z.object({
workflowIdOrName: z.string().min(1).title('Workflow ID or Name').describe('The workflow ID or name'),
conversationId: z
.string()
.min(1)
.placeholder('{{ event.conversationId }}')
.title('Conversation ID')
.describe('The current Botpress conversation ID — use {{event.conversationId}}'),
payload: z
.record(z.string(), z.any())
.optional()
.default({})
.title('Payload')
.describe('The JSON payload to send to the workflow'),
}),
},
output: {
schema: z.object({
workflowId: z.string().optional().title('Workflow ID').describe('The ID of the triggered workflow'),
workflowName: z.string().optional().title('Workflow Name').describe('The name of the triggered workflow'),
response: z.any().optional().title('Response').describe('The response body returned by the n8n webhook'),
}),
},
},
},
events: {
n8nEvent: {
title: 'n8n event',
description: 'Triggered when n8n posts data back to Botpress.',
schema: z.object({
workflowId: z
.string()
.optional()
.title('Workflow ID')
.describe('The ID of the workflow that posted this event'),
workflowName: z
.string()
.optional()
.title('Workflow Name')
.describe('The name of the workflow that posted this event'),
data: z.record(z.string(), z.any()).title('Data').describe('Arbitrary data payload sent by the n8n workflow'),
}),
},
},
attributes: {
category: 'Developer Tools',
repo: 'botpress',
},
})
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@botpresshub/n8n",
"scripts": {
"check:type": "tsc --noEmit",
"build": "bp add -y && bp build",
"check:bplint": "bp lint"
},
"private": true,
"dependencies": {
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"axios": "^1.7.9"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { createActionWrapper } from '@botpress/common'
import { N8nClient } from './client'
import * as bp from '.botpress'
const _wrapAction = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
n8nClient: ({ ctx }) => new N8nClient(ctx.configuration),
},
})
export const wrapAction: typeof _wrapAction = (meta, actionImpl) =>
_wrapAction(meta, (props) => {
props.logger.forBot().debug(`Running action "${meta.actionName}"`, { input: props.input })
return actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
})
@@ -0,0 +1,6 @@
import { wrapAction } from '../action-wrapper'
export const getWorkflow = wrapAction({ actionName: 'getWorkflow' }, async ({ n8nClient }, input) => {
const workflow = await n8nClient.getWorkflow(input.workflowId, input.excludePinnedData ?? true)
return { workflow }
})
+9
View File
@@ -0,0 +1,9 @@
import { getWorkflow } from './get-workflow'
import { listWorkflows } from './list-workflows'
import { triggerWorkflow } from './trigger-workflow'
export default {
listWorkflows,
getWorkflow,
triggerWorkflow,
}
@@ -0,0 +1,5 @@
import { wrapAction } from '../action-wrapper'
export const listWorkflows = wrapAction({ actionName: 'listWorkflows' }, async ({ n8nClient }, input) =>
n8nClient.listWorkflows(input)
)
@@ -0,0 +1,11 @@
import { wrapAction } from '../action-wrapper'
export const triggerWorkflow = wrapAction({ actionName: 'triggerWorkflow' }, async ({ n8nClient }, input) =>
n8nClient.triggerWorkflowWebhook({
workflowIdOrName: input.workflowIdOrName,
body: {
conversationId: input.conversationId,
data: input.payload ?? {},
},
})
)
+160
View File
@@ -0,0 +1,160 @@
import * as sdk from '@botpress/sdk'
import axios, { type AxiosInstance } from 'axios'
import { isNotFoundResponse, wrapN8nError, wrapRegistrationError } from './errors'
import type { N8nConfiguration, N8nWorkflow, ListWorkflowsInput, TriggerWorkflowWebhookInput } from './types'
const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook'
export class N8nClient {
private readonly _baseUrl: string
private readonly _client: AxiosInstance
public constructor(configuration: N8nConfiguration) {
this._baseUrl = configuration.baseUrl
this._client = axios.create({
baseURL: this._buildApiUrl('/'),
headers: {
'Content-Type': 'application/json',
'X-N8N-API-KEY': configuration.accessKey,
},
})
}
public async validateConnection(): Promise<void> {
try {
await this._client.get('/workflows', {
params: {
limit: 1,
excludePinnedData: true,
},
})
} catch (error) {
return wrapRegistrationError(error, this._baseUrl)
}
}
public async listWorkflows(input: ListWorkflowsInput) {
try {
const { data } = await this._client.get('/workflows', {
params: {
active: input.active,
name: input.name,
tags: input.tags,
projectId: input.projectId,
excludePinnedData: input.excludePinnedData,
limit: input.limit,
cursor: input.cursor,
},
})
return {
data: data?.data ?? [],
nextCursor: data?.nextCursor ?? undefined,
}
} catch (error) {
return wrapN8nError(error)
}
}
public async getWorkflow(workflowId: string, excludePinnedData = true): Promise<N8nWorkflow> {
try {
return await this._getWorkflowById(workflowId, excludePinnedData)
} catch (error) {
if (isNotFoundResponse(error)) {
throw new sdk.RuntimeError(`n8n workflow "${workflowId}" not found`)
}
return wrapN8nError(error)
}
}
public async triggerWorkflowWebhook(input: TriggerWorkflowWebhookInput) {
const workflow = await this._resolveWorkflowByIdOrName(input.workflowIdOrName)
const webhookPath = this._getWebhookPath(workflow)
if (!webhookPath) {
throw new sdk.RuntimeError('Unable to find an n8n webhook node with a path parameter in the selected workflow')
}
const webhookUrl = this._buildPublicUrl(`/webhook/${webhookPath.replace(/^\/+/, '')}`)
try {
// Webhook URLs are public endpoints, no need to use the API key.
const response = await axios.post(webhookUrl, {
...input.body,
workflowId: workflow.id,
workflowName: workflow.name,
})
return {
workflowId: workflow.id,
workflowName: workflow.name,
response: response.data,
}
} catch (error) {
return wrapN8nError(error, 'n8n webhook trigger')
}
}
private async _resolveWorkflowByIdOrName(workflowIdOrName: string): Promise<N8nWorkflow> {
try {
return await this._getWorkflowById(workflowIdOrName)
} catch (error) {
if (!isNotFoundResponse(error)) {
return wrapN8nError(error)
}
}
try {
const { data } = await this._client.get<{ data?: N8nWorkflow[] }>('/workflows', {
params: {
name: workflowIdOrName,
excludePinnedData: true,
},
})
const exactMatch = (data?.data ?? []).find(
(workflow) => workflow?.name === workflowIdOrName || workflow?.id === workflowIdOrName
)
if (exactMatch?.id) {
return await this._getWorkflowById(exactMatch.id)
}
} catch (error) {
return wrapN8nError(error)
}
throw new sdk.RuntimeError(`Unable to resolve n8n workflow "${workflowIdOrName}"`)
}
private async _getWorkflowById(workflowId: string, excludePinnedData = true): Promise<N8nWorkflow> {
const { data } = await this._client.get<N8nWorkflow>(`/workflows/${encodeURIComponent(workflowId)}`, {
params: {
excludePinnedData,
},
})
return data
}
private _getWebhookPath(workflow: N8nWorkflow): string | undefined {
const nodes = workflow?.nodes ?? workflow?.activeVersion?.nodes ?? []
const webhookNode = nodes.find((node) => node?.type === WEBHOOK_NODE_TYPE)
const path = webhookNode?.parameters?.path
return typeof path === 'string' ? path : undefined
}
private _buildApiUrl(path: string): string {
return this._buildUrl(path, true)
}
private _buildPublicUrl(path: string): string {
return this._buildUrl(path, false)
}
private _buildUrl(path: string, api: boolean): string {
const base = this._baseUrl.replace(/\/+$/, '')
const prefix = api ? '/api/v1' : ''
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${base}${prefix}${normalizedPath}`
}
}
+41
View File
@@ -0,0 +1,41 @@
import * as sdk from '@botpress/sdk'
import axios from 'axios'
export const isNotFoundResponse = (error: unknown): boolean =>
axios.isAxiosError(error) && error.response?.status === 404
export const wrapN8nError = (error: unknown, context = 'n8n request'): never => {
if (axios.isAxiosError(error)) {
const status = error.response?.status
const detail = status ? `HTTP ${status}` : (error.code ?? error.message ?? 'network error')
throw new sdk.RuntimeError(`${context} failed: ${detail}`)
}
throw new sdk.RuntimeError(error instanceof Error ? error.message : String(error))
}
export const wrapRegistrationError = (error: unknown, baseUrl: string): never => {
if (axios.isAxiosError(error)) {
const status = error.response?.status
const code = error.code
const message = status ? `n8n responded with HTTP ${status}` : (code ?? error.message ?? 'network error')
if (status === 401 || status === 403) {
throw new sdk.RuntimeError(
`Registration failed: authentication rejected (${message}). Check your Access Key and permissions.`
)
}
if (status === 404) {
throw new sdk.RuntimeError(
`Registration failed: the n8n API was not found at ${baseUrl}/api/v1 (HTTP 404). Ensure your Base URL points to the root of your n8n instance (for example https://example.app.n8n.cloud).`
)
}
throw new sdk.RuntimeError(
`Registration failed: unable to reach n8n (${message}). Verify the Base URL is correct and reachable from this host.`
)
}
const message = error instanceof Error ? error.message : String(error)
throw new sdk.RuntimeError(`Registration failed: ${message}`)
}
+84
View File
@@ -0,0 +1,84 @@
import { z } from '@botpress/sdk'
import actions from './actions'
import { N8nClient } from './client'
import * as bp from '.botpress'
const webhookPayloadSchema = z.object({
conversationId: z.string().optional(),
workflowId: z.string().optional(),
workflowName: z.string().optional(),
data: z.union([z.record(z.string(), z.any()), z.string()]).optional(),
})
const parseJsonBody = (body: unknown): unknown | null => {
if (typeof body !== 'string') {
return body
}
try {
return JSON.parse(body)
} catch {
return null
}
}
export default new bp.Integration({
register: async ({ ctx }) => {
await new N8nClient(ctx.configuration).validateConnection()
},
unregister: async () => undefined,
actions,
channels: {},
handler: async ({ req, client, logger }) => {
const log = logger.forBot()
const body = parseJsonBody(req.body)
if (!body || typeof body !== 'object') {
log.error('n8n webhook request must contain a valid JSON object body')
return
}
const result = webhookPayloadSchema.safeParse(body)
if (!result.success) {
log.error('Invalid n8n webhook payload structure')
return
}
const payload = result.data
if (!payload.conversationId) {
log.error('Missing conversationId in n8n webhook payload — ensure your n8n workflow echoes it back')
return
}
let data: Record<string, any>
if (typeof payload.data === 'string') {
try {
data = JSON.parse(payload.data)
} catch {
log.error('n8n webhook payload.data contained invalid JSON')
return
}
} else if (typeof payload.data === 'object' && payload.data !== null) {
data = payload.data
} else {
data = {}
}
try {
await client.createEvent({
type: 'n8nEvent',
conversationId: payload.conversationId,
payload: {
workflowId: payload.workflowId,
workflowName: payload.workflowName,
data,
},
})
} catch (err) {
log.error(`Failed to create n8n event: ${err instanceof Error ? err.message : String(err)}`)
return
}
return { status: 200, body: JSON.stringify({ ok: true }) }
},
})
+33
View File
@@ -0,0 +1,33 @@
export type N8nNode = {
type: string
parameters?: Record<string, unknown>
}
export type N8nWorkflow = {
id?: string
name?: string
nodes?: N8nNode[]
activeVersion?: {
nodes?: N8nNode[]
}
}
export type N8nConfiguration = {
baseUrl: string
accessKey: string
}
export type ListWorkflowsInput = {
active?: boolean
name?: string
tags?: string
projectId?: string
excludePinnedData?: boolean
limit?: number
cursor?: string
}
export type TriggerWorkflowWebhookInput = {
workflowIdOrName: string
body: Record<string, any>
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}