chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { formSchema } from './tally-events'
|
||||
import { listSubmissionsInputSchema, listSubmissionsOuputSchema } from './tally-submissions'
|
||||
|
||||
export default {
|
||||
listSubmissionsInputSchema,
|
||||
listSubmissionsOuputSchema,
|
||||
formSchema,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { tallyFieldSchema } from './tally-fields'
|
||||
|
||||
export const formSchema = z.object({
|
||||
formId: z.string().min(1).describe('Form ID').title('Form ID'),
|
||||
fields: z.array(tallyFieldSchema).default([]).describe('Form fields').title('Fields'),
|
||||
})
|
||||
|
||||
export const webhookSchema = z
|
||||
.object({
|
||||
eventId: z.string().describe('Event ID').title('Event ID'),
|
||||
eventType: z.literal('FORM_RESPONSE').describe('Event Type').title('Event Type'),
|
||||
createdAt: z.string().describe('Creation Date').title('Created At'),
|
||||
data: z
|
||||
.object({
|
||||
responseId: z.string().optional().describe('Response ID').title('Response ID'),
|
||||
submissionId: z.string().optional().describe('Submission ID').title('Submission ID'),
|
||||
respondentId: z.string().optional().describe('Respondent ID').title('Respondent ID'),
|
||||
formId: z.string().describe('Form ID').title('Form ID'),
|
||||
formName: z.string().optional().describe('Form Name').title('Form Name'),
|
||||
createdAt: z.string().optional().describe('Creation Date').title('Created At'),
|
||||
fields: z.array(tallyFieldSchema).default([]).describe('Form fields').title('Fields'),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough()
|
||||
@@ -0,0 +1,91 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
const baseField = z
|
||||
.object({
|
||||
key: z.string().min(1).title('Key').describe('Field key identifier'),
|
||||
label: z.string().nullable().optional().title('Label').describe('Field label'),
|
||||
type: z.string().min(1).title('Type').describe('Field type'),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const optionSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).title('ID').describe('Option ID'),
|
||||
text: z.string().nullable().optional().title('Text').describe('Option text'),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const fileSchema = z
|
||||
.object({
|
||||
id: z.string().optional().title('ID').describe('File ID'),
|
||||
name: z.string().nullable().optional().title('Name').describe('File name'),
|
||||
url: z.string().url().optional().title('URL').describe('File URL'),
|
||||
mimeType: z.string().nullable().optional().title('MIME Type').describe('File MIME type'),
|
||||
size: z.number().optional().title('Size').describe('File size in bytes'),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const optionsFieldBase = baseField.extend({
|
||||
value: z.array(z.string()).nullable().optional().title('Value').describe('Selected option values'),
|
||||
options: z.array(optionSchema).optional().title('Options').describe('Available options'),
|
||||
})
|
||||
|
||||
const multiSelectField = optionsFieldBase.extend({
|
||||
type: z.literal('MULTI_SELECT').title('Type').describe('Field type'),
|
||||
})
|
||||
const dropdownField = optionsFieldBase.extend({
|
||||
type: z.literal('DROPDOWN').title('Type').describe('Field type'),
|
||||
})
|
||||
const multipleChoiceField = optionsFieldBase.extend({
|
||||
type: z.literal('MULTIPLE_CHOICE').title('Type').describe('Field type'),
|
||||
})
|
||||
const rankingField = optionsFieldBase.extend({
|
||||
type: z.literal('RANKING').title('Type').describe('Field type'),
|
||||
})
|
||||
|
||||
const checkboxesField = baseField.extend({
|
||||
type: z.literal('CHECKBOXES').title('Type').describe('Field type'),
|
||||
value: z
|
||||
.union([z.array(z.string()), z.boolean()])
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Value')
|
||||
.describe('Checkbox values'),
|
||||
options: z.array(optionSchema).optional().title('Options').describe('Available checkbox options'),
|
||||
})
|
||||
|
||||
const fileUploadField = baseField.extend({
|
||||
type: z.literal('FILE_UPLOAD').title('Type').describe('Field type'),
|
||||
value: z.array(fileSchema).nullable().optional().title('Value').describe('Uploaded files'),
|
||||
})
|
||||
const signatureField = baseField.extend({
|
||||
type: z.literal('SIGNATURE').title('Type').describe('Field type'),
|
||||
value: z.array(fileSchema).nullable().optional().title('Value').describe('Signature files'),
|
||||
})
|
||||
|
||||
const matrixField = baseField.extend({
|
||||
type: z.literal('MATRIX').title('Type').describe('Field type'),
|
||||
value: z.record(z.array(z.string())).nullable().optional().title('Value').describe('Matrix values'),
|
||||
rows: z.array(optionSchema).optional().title('Rows').describe('Matrix rows'),
|
||||
columns: z.array(optionSchema).optional().title('Columns').describe('Matrix columns'),
|
||||
})
|
||||
|
||||
const knownSpecialFieldSchema = z.discriminatedUnion('type', [
|
||||
multiSelectField,
|
||||
dropdownField,
|
||||
checkboxesField,
|
||||
multipleChoiceField,
|
||||
rankingField,
|
||||
fileUploadField,
|
||||
signatureField,
|
||||
matrixField,
|
||||
])
|
||||
|
||||
const fallbackFieldSchema = baseField
|
||||
.extend({
|
||||
value: z.any().optional().title('Value').describe('Field value'),
|
||||
options: z.any().optional().title('Options').describe('Field options'),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const tallyFieldSchema = z.union([knownSpecialFieldSchema, fallbackFieldSchema])
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const listSubmissionsInputSchema = z.object({
|
||||
formId: z.string().title('Form ID').describe('Tally form ID'),
|
||||
page: z.number().int().min(1).optional().describe('Page number for pagination').title('Page'),
|
||||
filter: z.enum(['all', 'completed', 'partial']).optional().describe('Filter submissions by status').title('Filter'),
|
||||
startDate: z.string().optional().describe('Start date for filtering submissions').title('Start Date'),
|
||||
endDate: z.string().optional().describe('End date for filtering submissions').title('End Date'),
|
||||
afterId: z.string().optional().describe('ID of the submission after which to start listing').title('After ID'),
|
||||
})
|
||||
|
||||
export const listSubmissionsOuputSchema = z.object({
|
||||
questions: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().describe('Question ID').title('Question ID'),
|
||||
type: z.string().optional().describe('Question type').title('Type'),
|
||||
title: z.string().nullable().optional().describe('Question title').title('Title'),
|
||||
})
|
||||
)
|
||||
.describe('List of questions')
|
||||
.title('Questions'),
|
||||
submissions: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().describe('Submission ID').title('Submission ID'),
|
||||
responses: z
|
||||
.array(
|
||||
z.object({
|
||||
questionId: z.string().describe('Question ID').title('Question ID'),
|
||||
value: z.any().optional().describe('Response value').title('Value'),
|
||||
answer: z.any().optional().describe('Response answer').title('Answer'),
|
||||
})
|
||||
)
|
||||
.describe('List of responses for this submission')
|
||||
.title('Responses'),
|
||||
})
|
||||
)
|
||||
.describe('List of submissions')
|
||||
.title('Submissions'),
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
# Tally Integration
|
||||
|
||||
Connect your Botpress chatbot with **Tally**, a powerful online form builder, and receive form submissions directly in your bot.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before enabling the Botpress Tally integration, make sure you have:
|
||||
|
||||
- A **Tally API key**
|
||||
- The **Form ID** of the Tally form you want to connect to your bot
|
||||
|
||||
### Where to find prerequisites
|
||||
|
||||
#### Tally API Key
|
||||
|
||||
1. Go to settings
|
||||
2. Click the tab **API keys**
|
||||
3. Click the button **Create API key**
|
||||
4. Choose a name for your key
|
||||
5. Save this key for usage
|
||||
|
||||
#### Form ID
|
||||
|
||||
The form id can be found in the url of your form or in the tab **share** inside the share link.
|
||||
It is the ID after the url of the sharelink.
|
||||
|
||||
`https://tally.so/r/[This value here]`
|
||||
|
||||
## Usage
|
||||
|
||||
Once the integration is enabled, your bot can react to events sent by Tally.
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M179.251 67.0119C179.251 80.2348 174.369 98.4265 167.172 122.873L162.023 141.649L178.318 131.005C209.291 109.256 227.996 101.093 240.377 101.093C254.459 101.093 269 111.156 269 131.17C269 156.629 241.819 163.861 189.342 164.753L169.721 165.675L185.195 177.993C221.931 206.309 234.666 220.181 234.666 237.758C234.666 251.591 221.81 266 205.87 266C181.92 266 171.095 241.112 156.502 198.86L149.064 180.495L142.065 198.86C125.71 246.852 113.122 265.558 92.2576 265.558C75.4361 265.558 63.4615 250.267 63.4615 237.317C63.4615 217.973 81.0431 201.894 113.373 177.993L128.847 165.675L109.667 164.753C53.9319 164.303 30 156.287 30 130.728C30 110.715 44.9899 100.651 58.8047 100.651C75.4879 100.651 93.683 111.905 120.691 131.005L136.985 141.649L131.836 122.873C124.034 97.102 120.371 78.1962 120.371 67.0119C120.371 49.504 130.238 35 149.504 35C169.211 35 179.251 49.504 179.251 67.0119Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1013 B |
@@ -0,0 +1,59 @@
|
||||
import { z, IntegrationDefinition } from '@botpress/sdk'
|
||||
import schemas from './definitions/schemas'
|
||||
|
||||
// TODO: use default options
|
||||
const toJSONSchemaOptions: Partial<z.transforms.JSONSchemaGenerationOptions> = {
|
||||
discriminatedUnionStrategy: 'anyOf',
|
||||
discriminator: false,
|
||||
}
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'tally',
|
||||
title: 'Tally',
|
||||
description: 'Integrate with Tally forms to capture form submissions and automate workflows.',
|
||||
version: '0.1.2',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
configuration: {
|
||||
schema: z.object({
|
||||
apiKey: z.string().min(1).title('API Key').describe('Tally API Key'),
|
||||
formIds: z.array(z.string().min(1)).min(1).title('Form IDs').describe('Tally form IDs'),
|
||||
signingSecret: z.string().min(1).optional().title('Signing Secret').describe('Webhook signing secret (optional)'),
|
||||
}),
|
||||
},
|
||||
states: {
|
||||
tallyIntegrationInfo: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
tallyWebhookIds: z
|
||||
.record(z.string().min(1), z.string().min(1))
|
||||
.title('Tally webhooks')
|
||||
.describe('Mapping of Tally form IDs to their registered webhook IDs'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
events: {
|
||||
formSubmitted: {
|
||||
title: 'Form Submitted',
|
||||
description: 'This event happens when the form is submitted',
|
||||
schema: schemas.formSchema,
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
listSubmissions: {
|
||||
title: 'List Submissions',
|
||||
description: 'List all the submissions for a specified form',
|
||||
input: {
|
||||
schema: schemas.listSubmissionsInputSchema,
|
||||
},
|
||||
output: {
|
||||
schema: schemas.listSubmissionsOuputSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
__advanced: { toJSONSchemaOptions },
|
||||
attributes: {
|
||||
category: 'Business Operations',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@botpresshub/tally",
|
||||
"description": "Tally 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/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { listSubmissionsInputSchema, listSubmissionsOuputSchema } from 'definitions/schemas/tally-submissions'
|
||||
|
||||
export type CreateWebhookReq = {
|
||||
formId: string
|
||||
url: string
|
||||
eventTypes: Array<'FORM_RESPONSE'>
|
||||
signingSecret?: string
|
||||
httpHeaders?: Array<{ name: string; value: string }>
|
||||
externalSubscriber?: string
|
||||
}
|
||||
|
||||
const createWebhookResSchema = z.object({
|
||||
id: z.string(),
|
||||
url: z.string().url(),
|
||||
eventTypes: z.array(z.literal('FORM_RESPONSE')),
|
||||
isEnabled: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
export type CreateWebhookRes = z.infer<typeof createWebhookResSchema>
|
||||
export type listSubmissionsRes = z.infer<typeof listSubmissionsOuputSchema>
|
||||
|
||||
const TALLY_BASE_URL = 'https://api.tally.so'
|
||||
|
||||
export class TallyApi {
|
||||
public constructor(private _apiKey: string) {}
|
||||
|
||||
public async createWebhook(body: CreateWebhookReq): Promise<CreateWebhookRes> {
|
||||
const res = await fetch(`${TALLY_BASE_URL}/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Tally createWebhook failed (${res.status} ${res.statusText})`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
return createWebhookResSchema.parse(json)
|
||||
}
|
||||
|
||||
public async deleteWebhook(tallyWebhookId: string): Promise<void> {
|
||||
const res = await fetch(`${TALLY_BASE_URL}/webhooks/${tallyWebhookId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${this._apiKey}` },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Tally deleteWebhook failed (${res.status} ${res.statusText})`)
|
||||
}
|
||||
}
|
||||
|
||||
public async listSubmissions(input: z.infer<typeof listSubmissionsInputSchema>): Promise<listSubmissionsRes> {
|
||||
const { formId, ...params } = input
|
||||
const qs = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === '') continue
|
||||
qs.set(key, String(value))
|
||||
}
|
||||
|
||||
const url = `${TALLY_BASE_URL}/forms/${formId}/submissions?${qs.toString()}`
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._apiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Tally listSubmissions failed (${res.status} ${res.statusText})`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
return listSubmissionsOuputSchema.parse(json)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { webhookSchema } from 'definitions/schemas/tally-events'
|
||||
import { TallyApi } from './client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default new bp.Integration({
|
||||
register: async ({ ctx, webhookUrl, logger, client }) => {
|
||||
const tallyApi = new TallyApi(ctx.configuration.apiKey)
|
||||
const formIds = ctx.configuration.formIds
|
||||
|
||||
const { state } = await client.getOrSetState({
|
||||
type: 'integration',
|
||||
name: 'tallyIntegrationInfo',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
tallyWebhookIds: {},
|
||||
},
|
||||
})
|
||||
const tallyWebhookIds: Record<string, string> = state.payload.tallyWebhookIds
|
||||
|
||||
try {
|
||||
for (const formId of formIds) {
|
||||
if (tallyWebhookIds[formId]) {
|
||||
logger.info('Webhook already registered for form', { formId })
|
||||
continue
|
||||
}
|
||||
|
||||
const created = await tallyApi.createWebhook({
|
||||
formId,
|
||||
url: webhookUrl,
|
||||
eventTypes: ['FORM_RESPONSE'],
|
||||
signingSecret: ctx.configuration.signingSecret,
|
||||
})
|
||||
|
||||
tallyWebhookIds[formId] = created.id
|
||||
logger.info('Tally webhook registered', {
|
||||
formId,
|
||||
webhookId: created.id,
|
||||
})
|
||||
}
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
name: 'tallyIntegrationInfo',
|
||||
payload: {
|
||||
tallyWebhookIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.forBot().error('Failed to create Tally webhook', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
unregister: async ({ ctx, client, logger }) => {
|
||||
const { state } = await client.getOrSetState({
|
||||
type: 'integration',
|
||||
name: 'tallyIntegrationInfo',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
tallyWebhookIds: {},
|
||||
},
|
||||
})
|
||||
const tallyWebhookIds: Record<string, string> = state.payload.tallyWebhookIds
|
||||
|
||||
if (!tallyWebhookIds) {
|
||||
logger.warn('No Tally webhooks found in state')
|
||||
return
|
||||
}
|
||||
const tallyApi = new TallyApi(ctx.configuration.apiKey)
|
||||
|
||||
for (const [formId, tallyWebhookId] of Object.entries(tallyWebhookIds)) {
|
||||
try {
|
||||
await tallyApi.deleteWebhook(tallyWebhookId)
|
||||
logger.info('Tally webhook deleted', { formId, tallyWebhookId })
|
||||
} catch (error) {
|
||||
logger.warn('Failed to delete Tally webhook', { formId, tallyWebhookId, error })
|
||||
}
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
listSubmissions: async ({ ctx, input, logger }) => {
|
||||
const tallyApi = new TallyApi(ctx.configuration.apiKey)
|
||||
|
||||
try {
|
||||
return await tallyApi.listSubmissions(input)
|
||||
} catch (error) {
|
||||
logger.warn('Error when listing submissions', { error })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
channels: {},
|
||||
handler: async ({ req, ctx, client, logger }) => {
|
||||
if (!req.body) {
|
||||
logger.warn('Event handler received an empty body')
|
||||
return
|
||||
}
|
||||
|
||||
const data = JSON.parse(req.body)
|
||||
const parsed = webhookSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
logger.warn('Invalid Tally webhook payload', parsed.error)
|
||||
return { status: 400, body: 'invalid payload' }
|
||||
}
|
||||
|
||||
const incomingFormId = parsed.data.data.formId
|
||||
const formIds = ctx.configuration.formIds
|
||||
|
||||
if (!formIds.includes(incomingFormId)) {
|
||||
logger.info('Ignoring webhook for unconfigured form', { incomingFormId })
|
||||
return { status: 200, body: 'ignored' }
|
||||
}
|
||||
|
||||
const fields = parsed.data.data.fields ?? []
|
||||
await client.createEvent({
|
||||
type: 'formSubmitted',
|
||||
payload: {
|
||||
formId: incomingFormId,
|
||||
fields,
|
||||
},
|
||||
})
|
||||
return { status: 200, body: 'ok' }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user