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,63 @@
import { ActionDefinition, z } from '@botpress/sdk'
const boardModel = {
id: z.string().title('id').describe('The unique identifier of the board.'),
category: z.string().title('Category').describe('The name of the board/category. Example: "Feature Requests"'),
private: z.boolean().title('Private').optional().describe('Flag indicating whether this board is private.'),
segmentIds: z.array(z.string()).title('Segment Ids').describe('An array of segment IDs associated with this board.'),
roles: z.array(z.string()).title('Roles').describe('An array of roles that have access to this board.'),
hiddenFromRoles: z
.array(z.string())
.title('Hidden From Roles')
.describe('An array of roles that cannot see this board.'),
disablePostCreation: z
.boolean()
.title('Disable Post Creation')
.optional()
.describe('Flag indicating whether post creation is disabled for this board.'),
disableFollowUpQuestions: z
.boolean()
.title('Disable Follow Up Questions')
.optional()
.describe('Flag indicating whether follow-up questions are disabled for this board.'),
customInputFields: z
.array(z.string())
.title('Custom Input Fields')
.describe('An array of custom input fields ids that apply to this board.'),
defaultAuthorOnly: z
.boolean()
.title('Default Author Only')
.optional()
.describe('Flag indicating whether posts in this board are visible to the author only by default.'),
defaultCompanyOnly: z
.boolean()
.title('Default Company Only')
.optional()
.describe('Flag indicating whether posts in this board are visible to the company only by default.'),
}
export const listBoards = {
title: 'List boards',
description: 'List all boards',
input: {
schema: z.object({}),
},
output: {
schema: z.object({
results: z.array(z.object(boardModel)).title('Results').describe('An array of boards.'),
}),
},
} satisfies ActionDefinition
export const getBoard = {
title: 'Get a board',
description: 'Get a board by ID',
input: {
schema: z.object({
id: z.string().optional().title('ID').describe('The unique identifier of the board to retrieve.'),
}),
},
output: {
schema: z.object(boardModel).title('Board').describe('A single board'),
},
} satisfies ActionDefinition
@@ -0,0 +1,81 @@
import { z, ActionDefinition } from '@botpress/sdk'
export const getComments = {
title: 'Get Comments',
description: 'Get a comments thread',
input: {
schema: z.object({
submissionId: z
.string()
.title('SubmissionId')
.describe('The id of the submission to get comments for.')
.optional(),
changelogId: z
.string()
.title('changelogId')
.describe(
'The id of the changelog to get comments for. Accepts both ObjectId and slug. Example: "65f5f3c037fc63b7d43d0f16" or "my-changelog-slug"'
)
.optional(),
privacy: z
.enum(['public', 'private', 'all'])
.title('Privacy')
.describe('Filter comments by privacy setting. Allowed values: "public", "private", "all"')
.optional(),
inReview: z
.boolean()
.title('In Review')
.describe('Filter comments by whether they are in review. Set to true to get only comments that are in review.')
.optional(),
commentThreadId: z
.string()
.title('Comment Thread Id')
.describe(
'Given a specific comment id, this will return all comments in the thread with the root comment being the comment with the given id.'
)
.optional(),
limit: z.number().title('Limit').describe('Number of results per page. Default is 10.').optional(),
page: z.number().title('Page').describe('Page number. Default is 1.').optional(),
sortBy: z.enum(['best', 'top', 'new', 'old']).title('SortBy').describe('Sort order of the results.').optional(),
nextToken: z.string().title('Next Token').optional().describe('Page number. Starts at 1'),
}),
},
output: {
schema: z.object({
results: z
.array(
z
.object({
id: z.string().title('ID').describe('Comment ID'),
upvoted: z.boolean().optional().title('Upvoted').describe('Whether the comment is upvoted'),
downvoted: z.boolean().optional().title('Downvoted').describe('Whether the comment is downvoted'),
inReview: z.boolean().optional().title('In Review').describe('Whether the comment is in review'),
isSpam: z.boolean().optional().title('Is Spam').describe('Whether the comment is marked as spam'),
pinned: z.boolean().optional().title('Pinned').describe('Whether the comment is pinned'),
emailSent: z.boolean().optional().title('Email Sent').describe('Whether an email notification was sent'),
sendNotification: z
.boolean()
.optional()
.title('Send Notification')
.describe('Whether to send notifications'),
organization: z.string().optional().title('Organization').describe('Organization ID'),
submission: z.string().optional().title('Submission').describe('Submission ID'),
author: z.string().optional().title('Author').describe('Author name'),
authorId: z.string().optional().title('Author ID').describe('Author ID'),
authorPicture: z.string().optional().title('Author Picture').describe('Author profile picture URL'),
isPrivate: z.boolean().optional().title('Is Private').describe('Whether the comment is private'),
isDeleted: z.boolean().optional().title('Is Deleted').describe('Whether the comment is deleted'),
content: z.string().optional().title('Content').describe('Comment content'),
upvotes: z.number().optional().title('Upvotes').describe('Number of upvotes'),
downvotes: z.number().optional().title('Downvotes').describe('Number of downvotes'),
parentComment: z.string().nullable().optional().title('Parent Comment').describe('Parent comment ID'),
path: z.string().optional().title('Path').describe('Comment path'),
})
.title('Comment')
.describe('Represent a single comment.')
)
.title('Comments')
.describe('A list of comments.'),
}),
},
} satisfies ActionDefinition
@@ -0,0 +1,3 @@
export * from './boards'
export * from './comments'
export * from './posts'
@@ -0,0 +1,158 @@
import { z, ActionDefinition } from '@botpress/sdk'
export const createPost = {
title: 'Create a post',
description: 'Create a post on feature base',
input: {
schema: z.object({
title: z.string().title('Title').describe('The title of the submission. It must be at least 2 characters long.'),
category: z.string().title('Category').describe('The board (a.k.a category) of the submission.'),
content: z
.string()
.title('Content')
.optional()
.describe('The content of the submission. Can be an empty string.'),
email: z
.string()
.title('Email')
.optional()
.describe(
'The email of the user submitting the post. Will create a new user if the email is not associated with an existing user.'
),
authorName: z
.string()
.title('Author Name')
.optional()
.describe(
'Used when you provide an email. If the email is not associated with an existing user, a new user will be created with this name.'
),
tags: z
.array(z.string())
.title('Tags')
.optional()
.describe('The tags associated with the submission. Needs to be an array of tag names.'),
commentsAllowed: z
.boolean()
.title('Comments Allowed')
.optional()
.describe('Flag indicating whether comments are allowed on the submission.'),
status: z.string().title('Status').optional().describe('The status of the submission.'),
date: z.date().title('Date').optional().describe('Set the post creation date.'),
}),
},
output: {
schema: z.object({
submission: z
.object({
id: z.string().title('ID').describe('Submission ID'),
})
.title('Submission')
.describe('Represent the created post.'),
}),
},
} satisfies ActionDefinition
export const listPosts = {
title: 'List posts',
description: 'List all posts',
input: {
schema: z.object({
id: z.string().title('ID').optional().describe("Find submission by it's id."),
q: z.string().title('Query').optional().describe('Search for posts by title or content.'),
category: z
.array(z.string())
.title('Category')
.optional()
.describe('Filter posts by providing an array of category(board) names.'),
status: z.array(z.string()).title('Status').optional().describe('Filter posts by status ids.'),
sortBy: z.string().title('Sort By').optional().describe('Sort posts by a specific attribute.'),
startDate: z.date().title('Start Date').optional().describe('Get posts created after a specific date.'),
endDate: z.date().title('End Date').optional().describe('Get posts created before a specific date.'),
limit: z.number().title('Limit').optional().describe('Number of results per page'),
page: z.number().title('Page').optional().describe('Page number. Starts at 1'),
nextToken: z.string().title('Next Token').optional().describe('Page number. Starts at 1'),
}),
},
output: {
schema: z.object({
nextToken: z.string().title('Next Token').optional().describe('Use the token to fetch the next page of posts.'),
results: z
.array(
z.object({
title: z.string(),
content: z.string(),
author: z.string(),
authorId: z.string(),
organization: z.string(),
postCategory: z.object({
category: z.string(),
}),
id: z.string(),
})
)
.title('Results')
.describe('An array of posts.'),
}),
},
} satisfies ActionDefinition
export const updatePost = {
title: 'Update post',
description: 'Update a post',
input: {
schema: z.object({
id: z.string().title('ID').describe('The id of the submission.'),
title: z.string().title('Title').optional().describe('The title of the post. Example: "Add dark mode support"'),
content: z
.string()
.title('Content')
.optional()
.describe(
'The HTML content of the post. Example: "<p>It would be great to have dark mode support for better viewing at night.</p>"'
),
status: z.string().title('Status').optional().describe('The status of the submission. Example: "In Progress"'),
commentsAllowed: z
.boolean()
.title('Comments Allowed')
.optional()
.describe('Flag indicating whether comments are allowed on the submission. Example: true'),
category: z
.string()
.title('Category')
.optional()
.describe('The category of the submission. Example: "💡 Feature Request"'),
sendStatusUpdateEmail: z
.boolean()
.title('Send Status Update Email')
.optional()
.describe('Flag indicating whether to send a status update email to the upvoters. Default: false'),
tags: z
.array(z.string())
.title('Tags')
.optional()
.describe('The tags of the submission. Example: ["tag1", "tag2"]'),
inReview: z
.boolean()
.title('In Review')
.optional()
.describe('Flag indicating whether the submission is in review. In review posts are not visible to users.'),
date: z.date().title('Date').optional().describe('The post creation date.'),
}),
},
output: {
schema: z.object({}),
},
} satisfies ActionDefinition
export const deletePost = {
title: 'Delete post',
description: 'Delete a post',
input: {
schema: z.object({
id: z.string().title('ID').describe('The id of the submission.'),
}),
},
output: {
schema: z.object({}),
},
} satisfies ActionDefinition
@@ -0,0 +1,29 @@
import * as sdk from '@botpress/sdk'
export const comments = {
title: 'Comments',
description: 'Comment section of a post',
messages: {
text: sdk.messages.defaults.text,
},
message: {
tags: {
id: {
title: 'ID',
description: 'The Feature Base ID of the comment',
},
},
},
conversation: {
tags: {
rootCommentId: {
title: 'Root Comment ID',
description: 'The Feature Base ID of the root comment of the reply chain',
},
submissionId: {
title: 'Submission ID',
description: 'The Feature Base ID of the submission (post) where the comment was posted',
},
},
},
} satisfies sdk.ChannelDefinition
@@ -0,0 +1,23 @@
import { z } from '@botpress/sdk'
export const webhookEvent = z.object({
type: z.string().title('Type').describe('The type of event'),
organizationId: z.string().title('Organization Id').describe('Organization associated with the event'),
id: z.string().title('Id').describe('ID of the event'),
webhookId: z.string().title('Webhook Id').describe('The ID of the webhook').optional(),
createdAt: z.string().title('Created At').describe('Date when the event was created').optional(),
deliveryStatus: z.string().title('Delivery Status').describe('The status of the event').optional(),
firstSentAt: z.string().title('First Sent At').describe('First delivery time').optional(),
deliveryAttempts: z
.number()
.title('Delivery Attempts')
.describe('The number of times the event tried to be delivered')
.optional(),
})
export const userSchema = z.object({
id: z.string(),
email: z.string().optional(),
name: z.string().optional(),
profilePicture: z.string().optional(),
})
@@ -0,0 +1 @@
export * from './posts'
@@ -0,0 +1,96 @@
import { z, EventDefinition } from '@botpress/sdk'
import { webhookEvent, userSchema } from './common'
const postSchema = z.object({
id: z.string().title('Id').optional(),
type: z.string().title('Type').optional(),
title: z.string().title('Title').optional(),
content: z.string().title('Content').optional(),
user: userSchema.title('User').optional(),
postStatus: z
.object({
name: z.string().optional(),
type: z.string().optional(),
id: z.string().optional(),
})
.title('Post Status')
.optional(),
postCategory: z
.object({
category: z.string().optional(),
id: z.string().optional(),
})
.title('Post Category')
.optional(),
date: z.string().title('Date').optional(),
slug: z.string().title('Slug').optional(),
categoryId: z.string().title('Category Id').optional(),
})
export const postCreated = {
title: 'Post Created',
description: 'A post was created on a board.',
schema: webhookEvent.extend({
topic: z.literal('post.created').title('Topic').describe('The topic of the event'),
data: z
.object({
item: postSchema,
})
.title('Data')
.describe('Event data'),
}),
} satisfies EventDefinition
export const postUpdated = {
title: 'Post Updated',
description: 'A post was updated on a board.',
schema: webhookEvent.extend({
topic: z.literal('post.updated').title('Topic').describe('The topic of the event'),
data: z
.object({
item: postSchema,
changes: z.array(
z.object({
field: z.string(),
oldValue: z.any(),
newValue: z.any(),
})
),
})
.title('Data')
.describe('Event data'),
}),
} satisfies EventDefinition
export const postDeleted = {
title: 'Post Deleted',
description: 'A post was deleted on a board.',
schema: webhookEvent.extend({
topic: z.literal('post.deleted').title('Topic').describe('The topic of the event'),
data: z
.object({
item: postSchema,
})
.title('Data')
.describe('Event data'),
}),
} satisfies EventDefinition
export const postVoted = {
title: 'Post voted',
description: 'A post was voted on a board.',
schema: webhookEvent.extend({
topic: z.literal('post.voted').title('Topic').describe('The topic of the event'),
data: z
.object({
item: z.object({
type: z.string().optional(),
action: z.string().optional(),
submissionId: z.string().optional(),
user: userSchema.optional(),
}),
})
.title('Data')
.describe('Event data'),
}),
} satisfies EventDefinition
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+19
View File
@@ -0,0 +1,19 @@
# Feature Base integration
## Description
This integration allows Botpress bots to interact with Feature Bases API. By connecting your bot to Feature Base, you can dynamically manage and display content such as posts or boards without leaving the conversation. Whether its retrieving live data, adding new entries, or updating existing ones, this integration turns your chatbot into a real-time content manager for Feature Base.
## Getting started
### Configuration
#### Actions
Your API key is needed to configure this integration. How to find your API key is describe in [this documentation](https://docs.featurebase.app/quickstart).
#### Events
The webhook URL found on the configuration page of this integration should be added in the Feature Base's dashboard. [See this documentation for more information](https://docs.featurebase.app/webhooks#registering-webhooks)
It is also necessary to add the webhook signing secret to the configuration. The secret can be found in Feature Base's dashboard as mentioned in the previous paragraph.
+13
View File
@@ -0,0 +1,13 @@
<svg class="h-5 w-5" viewBox="0 0 22 22" fill="#804BE6" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_746_218)" fill="#804BE6">
<path d="M19.1064 5.83416C20.158 6.56113 20.9798 7.5734 21.4753 8.75178C21.9708 9.93015 22.1194 11.2256 21.9034 12.4855C21.6875 13.7455 21.1159 14.9175 20.2564 15.8636C19.3966 16.8096 18.2845 17.4905 17.051 17.8257C16.5252 17.966 15.9676 17.9289 15.4649 17.7204C14.9622 17.5117 14.5422 17.1434 14.27 16.6719C13.9978 16.2007 13.8887 15.6527 13.9593 15.113C14.03 14.5733 14.2765 14.072 14.6609 13.6866L16.5655 11.7805C17.3635 10.9869 17.983 10.0324 18.3831 8.98044C18.783 7.92853 18.954 6.80343 18.8848 5.68018C18.954 5.73046 19.031 5.78073 19.1064 5.83416Z" fill="#804BE6"></path>
<path d="M5.83416 2.89086C6.56162 1.83999 7.57408 1.01873 8.75243 0.523713C9.93076 0.0286976 11.226 -0.11949 12.4857 0.0965962C13.7454 0.312681 14.9172 0.884048 15.8633 1.74347C16.8093 2.6029 17.49 3.71462 17.8257 4.94786C17.9671 5.47402 17.9308 6.0321 17.7226 6.53556C17.5144 7.03903 17.1459 7.45973 16.6743 7.73243C16.2027 8.00514 15.6542 8.11461 15.114 8.04386C14.5738 7.97311 14.0721 7.7261 13.6866 7.34115L11.782 5.43658C10.9869 4.64077 10.0321 4.02268 8.98054 3.62315C7.92898 3.2236 6.80464 3.05167 5.68173 3.11872C5.73045 3.04329 5.7823 2.96629 5.83416 2.89086Z" fill="#804BE6"></path>
<path d="M2.89086 16.1631C1.83999 15.4357 1.01873 14.4232 0.523713 13.2449C0.0286976 12.0665 -0.11949 10.7713 0.0965962 9.51164C0.312681 8.25193 0.884028 7.08015 1.74345 6.13414C2.60287 5.18812 3.71461 4.50723 4.94785 4.17159C5.47456 4.02776 6.03405 4.06225 6.53911 4.2697C7.04416 4.47713 7.46641 4.84585 7.73998 5.31836C8.01353 5.79087 8.12306 6.34062 8.0515 6.8819C7.97992 7.42318 7.73126 7.92556 7.34428 8.31074L5.43972 10.2153C5.17872 10.4773 4.93564 10.7566 4.71213 11.0513C3.57219 12.5614 3.00831 14.4285 3.12186 16.3171C3.04486 16.2668 2.96786 16.2165 2.89086 16.1631Z" fill="#804BE6"></path>
<path d="M16.1648 19.1064C15.4374 20.1578 14.4249 20.9795 13.2463 21.4748C12.0678 21.9701 10.7722 22.1185 9.51222 21.9024C8.2522 21.6862 7.08014 21.1147 6.134 20.2549C5.18786 19.3952 4.50702 18.2831 4.1716 17.0494C4.02861 16.5228 4.06372 15.9635 4.27149 15.4588C4.47927 14.9541 4.84802 14.5323 5.32039 14.2589C5.79276 13.9855 6.34227 13.876 6.88338 13.9472C7.42449 14.0185 7.92685 14.2666 8.31233 14.653L10.2169 16.5575C10.4778 16.8198 10.7572 17.063 11.0529 17.2851C12.562 18.4258 14.429 18.9898 16.3172 18.8754C16.2685 18.9524 16.2167 19.0309 16.1648 19.1064Z" fill="#804BE6"></path>
</g>
<defs>
<clipPath id="clip0_746_218">
<rect width="22" height="22" fill="white"></rect>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,56 @@
import { z, IntegrationDefinition } from '@botpress/sdk'
import { listBoards, getBoard, listPosts, createPost, deletePost, updatePost, getComments } from 'definitions/actions'
import { comments } from 'definitions/channels'
import { postCreated, postUpdated, postDeleted, postVoted } from 'definitions/events'
export default new IntegrationDefinition({
name: 'feature-base',
version: '1.0.3',
title: 'Feature Base',
description: 'Integration with Feature Base for Botpress',
readme: 'hub.md',
icon: 'icon.svg',
configuration: {
schema: z.object({
apiKey: z.string().min(1, 'API Key is required').describe('Your Feature Base API Key').title('API Key'),
webhookSecret: z
.string()
.min(1, 'Webhook signing secret is required')
.describe('The webhook signing secret')
.title('Webhook Signing Secret'),
}),
},
actions: {
listBoards,
getBoard,
createPost,
listPosts,
deletePost,
updatePost,
getComments,
},
events: {
postCreated,
postUpdated,
postDeleted,
postVoted,
},
channels: {
comments,
},
user: {
tags: {
id: {
title: 'ID',
description: 'The Feature Base ID of the user',
},
},
},
__advanced: {
useLegacyZuiTransformer: true,
},
attributes: {
category: 'Project Management',
repo: 'botpress',
},
})
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@botpresshub/feature-base",
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"axios": "^1.11.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
}
}
+125
View File
@@ -0,0 +1,125 @@
import { RuntimeError } from '@botpress/client'
import { FeatureBaseClient } from './feature-base-api/client'
import { CommentCreated } from './feature-base-api/sub-schemas'
import * as bp from '.botpress'
import { Actions } from '.botpress/implementation/typings/actions'
type MessageHandlerProps<T extends keyof bp.MessageProps['comments']> = bp.MessageProps['comments'][T]
export const handleOutgoingTextMessage = async (props: MessageHandlerProps<'text'>) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
const comment = await client
.createComment({
content: props.payload.text,
submissionId: props.conversation.tags.submissionId,
})
.catch((thrown: unknown) => {
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError('Failed to send message in Feature Base: ' + err.message)
})
await props.ack({
tags: {
id: comment.comment.id,
},
})
}
type ExtractTagsReturn = {
isRoot: boolean
tags: {
rootCommentId: string
submissionId: string
}
}
const extractTags = (comment: Actions['getComments']['output']['results'][0]): ExtractTagsReturn => {
if (!comment.path) {
throw new RuntimeError('Path is not defined. Not possible to extract a tag')
}
const pathParts = comment.path.split('/')
if (pathParts.length < 1) {
throw new RuntimeError('Path could not be parsed. Not possible to extract a tag')
}
if (pathParts.length === 1) {
return {
isRoot: true,
tags: {
submissionId: pathParts[0]!,
rootCommentId: comment.id,
},
}
}
return {
isRoot: pathParts.length <= 2,
tags: {
submissionId: pathParts[0]!,
rootCommentId: pathParts[1]!,
},
}
}
export const handleIncomingTextMessage = async (props: bp.HandlerProps, payload: CommentCreated) => {
if (!payload.data.item.user?.id || !payload.data.item.submission) {
return
}
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
const commentThread = await client.getComments({
submissionId: payload.data.item.submission,
commentThreadId: payload.data.item.id,
})
if (commentThread.results.length === 0) {
throw new RuntimeError('The comment does not exists.')
}
const comment = commentThread.results[0]!
const alreadyExistsMessage = await _findMessage(props, comment.id)
if (alreadyExistsMessage) {
// ignoring message sent by the bot
return
}
const { isRoot, tags } = extractTags(comment)
// We only want to respond to roots comment. If the comment is not at the root of the comments
// section we do not respond to the message
if (!isRoot) {
return
}
const { conversation } = await props.client.getOrCreateConversation({
channel: 'comments',
tags,
})
const { user } = await props.client.getOrCreateUser({
tags: {
id: payload.data.item.user.id,
},
name: comment.author,
pictureUrl: comment.authorPicture,
})
await props.client.getOrCreateMessage({
type: 'text',
payload: {
text: payload.data.item.content ?? '',
},
tags: {
id: comment.id,
},
userId: user.id,
conversationId: conversation.id,
})
}
const _findMessage = (props: bp.HandlerProps, commentId: string) =>
props.client
.listMessages({
tags: { id: commentId },
})
.then((res) => res.messages[0])
@@ -0,0 +1,148 @@
import { RuntimeError } from '@botpress/client'
import axios, { Axios, AxiosResponse } from 'axios'
import { CreateCommentInput, CreateCommentOutput } from './sub-schemas'
import * as bp from '.botpress'
type Actions = bp.actions.Actions
type Input<K extends keyof Actions> = Actions[K]['input']
export type ErrorResponse = {
code: number
message: string
}
type Output<K extends keyof Actions> = Actions[K]['output']
type ApiOutput<K extends keyof Actions> = Output<K> | ErrorResponse
type PagedApiOutput<K extends keyof Actions> =
| ErrorResponse
| (Omit<ApiOutput<K>, 'nextToken'> & {
page: number
limit: number
totalResults: number
})
export class FeatureBaseClient {
private _client: Axios
public constructor(apiKey: string) {
this._client = axios.create({
baseURL: 'https://do.featurebase.app',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
},
})
}
private _unwrapPagedResponse<K extends keyof Actions>(response: PagedApiOutput<K>): Output<K> {
if ('message' in response) {
throw new RuntimeError(response.message)
}
const { limit, page, totalResults, ...result } = response
let nextToken: string | undefined = undefined
if (limit * page < totalResults) {
nextToken = String(page + 1)
}
return {
...result,
nextToken,
}
}
private _parsePagedParams<K extends keyof Actions>(
params: Input<K>
): Omit<Input<K>, 'nextToken'> & { page?: number } {
if (!('nextToken' in params)) {
return params
}
let page: number | undefined = undefined
if (params.nextToken && !isNaN(Number(params.nextToken))) {
page = Number(params.nextToken)
}
return {
...params,
page,
}
}
private _unwrapResponse<K extends keyof Actions>(response: ApiOutput<K>): Output<K> {
if ('message' in response) {
throw new RuntimeError(response.message)
}
return response
}
private _handleAxiosError(thrown: unknown): never {
if (axios.isAxiosError(thrown)) {
throw new RuntimeError(thrown.response?.data?.message || thrown.message)
} else {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
public async listBoards(): Promise<Output<'listBoards'>> {
const response: AxiosResponse<ApiOutput<'listBoards'>> = await this._client
.get('/v2/boards')
.catch(this._handleAxiosError)
return this._unwrapResponse(response.data)
}
public async getBoard(params: Input<'getBoard'>): Promise<Output<'getBoard'>> {
const response: AxiosResponse<ApiOutput<'getBoard'>> = await this._client
.get(`/v2/boards/${params.id}`)
.catch(this._handleAxiosError)
return this._unwrapResponse(response.data)
}
public async listPosts(params: Input<'listPosts'>): Promise<Output<'listPosts'>> {
const response: AxiosResponse<PagedApiOutput<'listPosts'>> = await this._client
.get('/v2/posts', {
params: this._parsePagedParams(params),
})
.catch(this._handleAxiosError)
return this._unwrapPagedResponse(response.data)
}
public async createPost(params: Input<'createPost'>): Promise<Output<'createPost'>> {
const response: AxiosResponse<ApiOutput<'createPost'>> = await this._client
.post('/v2/posts', params)
.catch(this._handleAxiosError)
return this._unwrapResponse(response.data)
}
public async deletePost(params: Input<'deletePost'>): Promise<Output<'deletePost'>> {
const response: AxiosResponse<ApiOutput<'deletePost'>> = await this._client
.delete('/v2/posts', { data: params })
.catch(this._handleAxiosError)
return this._unwrapResponse(response.data)
}
public async updatePost(params: Input<'updatePost'>): Promise<Output<'deletePost'>> {
const response: AxiosResponse<ApiOutput<'updatePost'>> = await this._client
.patch('/v2/posts', params)
.catch(this._handleAxiosError)
return this._unwrapResponse(response.data)
}
public async createComment(params: CreateCommentInput): Promise<CreateCommentOutput> {
const response: AxiosResponse<CreateCommentOutput | ErrorResponse> = await this._client
.post('/v2/comment', params)
.catch(this._handleAxiosError)
if ('message' in response.data) {
throw new RuntimeError(response.data.message)
}
return response.data
}
public async getComments(params: Input<'getComments'>): Promise<Output<'getComments'>> {
const response: AxiosResponse<PagedApiOutput<'getComments'>> = await this._client
.get('/v2/comment', {
params: this._parsePagedParams(params),
})
.catch(this._handleAxiosError)
return this._unwrapPagedResponse(response.data)
}
}
@@ -0,0 +1,2 @@
export * from './client'
export * from './webhook'
@@ -0,0 +1,83 @@
import { z } from '@botpress/sdk'
import { userSchema, webhookEvent } from '../../definitions/events/common'
export type CommentCreated = z.infer<typeof commentCreatedSchema>
export const commentCreatedSchema = webhookEvent.extend({
topic: z.literal('comment.created').title('Topic').describe('The topic of the event'),
data: z
.object({
item: z.object({
type: z.string().optional(),
id: z.string().optional(),
content: z.string().optional(),
user: userSchema.optional(),
isPrivate: z.boolean().optional(),
score: z.number().optional(),
upvotes: z.number().optional(),
downvotes: z.number().optional(),
inReview: z.boolean().optional(),
pinned: z.boolean().optional(),
emailSent: z.boolean().optional(),
sendNotification: z.boolean().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
organization: z.string().optional(),
submission: z.string().optional(),
path: z.string().optional(),
}),
})
.title('Data')
.describe('Event data'),
})
export type CreateCommentInput = z.infer<typeof createCommentInputSchema>
export const createCommentInputSchema = z.object({
submissionId: z.string().title('SubmissionId').describe('The id of the submission to get comments for.').optional(),
changelogId: z
.string()
.title('changelogId')
.describe(
'The id of the changelog to get comments for. Accepts both ObjectId and slug. Example: "65f5f3c037fc63b7d43d0f16" or "my-changelog-slug"'
)
.optional(),
content: z.string().title('Content').describe('The content of the comment.').optional(),
parentCommentId: z
.string()
.title('Parent Comment ID')
.describe('The id of the parent comment if this comment is a reply to another comment.')
.optional(),
isPrivate: z
.boolean()
.title('IsPrivate')
.describe(
'Flag indicating whether the comment is private. Private comments are visible only to admins of the organization.'
)
.optional(),
sendNotification: z
.boolean()
.title('SendNotification')
.describe('Flag indicating whether to notify voters of the submission about the comment. Defaults to true.')
.optional(),
createdAt: z.date().title('Type').describe('').optional(),
author: z
.object({
name: z.string().title('Name').describe('Name of the user'),
email: z.string().title('Email').describe('Email of the user'),
profilePicture: z.string().title('Profile Picture').describe('Profile picture of the user'),
})
.title('Author')
.optional()
.describe(
'Post the comment under a specific user. If not provided, the comment will be posted under the owner user of the Featurebase account.'
),
})
export type CreateCommentOutput = z.infer<typeof createCommentOutputSchema>
export const createCommentOutputSchema = z.object({
comment: z
.object({
id: z.string(),
})
.title('Comment')
.describe('Represent the created comment.'),
})
@@ -0,0 +1,11 @@
import { z } from '@botpress/sdk'
import { postCreated, postUpdated, postDeleted, postVoted } from 'definitions/events/posts'
import { commentCreatedSchema } from './sub-schemas'
export const webhookRequestSchema = z.union([
postCreated.schema,
postUpdated.schema,
postDeleted.schema,
postVoted.schema,
commentCreatedSchema,
])
+122
View File
@@ -0,0 +1,122 @@
import { Request, z } from '@botpress/sdk'
import crypto from 'crypto'
import { handleIncomingTextMessage } from './channels'
import { webhookRequestSchema } from './feature-base-api'
import * as bp from '.botpress'
const webhookTopicSchema = z.object({
topic: z.string(),
})
const isHandeledTopic = (request: z.infer<typeof webhookTopicSchema>) => {
const topics: string[] = webhookRequestSchema.options.map((option) => option.shape.topic.value)
return topics.includes(request.topic)
}
const MAX_TIMESTAMP_DIFF_SECS = 300 // 5 minutes
type VerifyWebhookSignatureReturn =
| {
isSignatureValid: true
signatureError: null
}
| {
isSignatureValid: false
signatureError: string
}
const _verifyWebhookSignature = (secret: string, request: Request): VerifyWebhookSignatureReturn => {
const signature = request.headers['x-webhook-signature']
const timestamp = request.headers['x-webhook-timestamp']
if (!signature || !timestamp) {
return {
isSignatureValid: false,
signatureError: 'Missing signature headers',
}
}
const timestampDiff = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp))
if (timestampDiff > MAX_TIMESTAMP_DIFF_SECS || isNaN(timestampDiff)) {
return {
isSignatureValid: false,
signatureError: 'Webhook timestamp too old or incorrect',
}
}
const signedPayload = `${timestamp}.${request.body}`
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signature, 'utf8'))) {
return {
isSignatureValid: false,
signatureError: 'Signature invalid',
}
}
return {
isSignatureValid: true,
signatureError: null,
}
}
export const handler: bp.IntegrationProps['handler'] = async (props) => {
const { isSignatureValid, signatureError } = _verifyWebhookSignature(props.ctx.configuration.webhookSecret, props.req)
if (!isSignatureValid) {
props.logger.error(`Webhook Signature Verification: ${signatureError}`)
return
}
if (!props.req.body) {
props.logger.error('Handler received an empty body')
return
}
let json: unknown | null = null
try {
json = JSON.parse(props.req.body)
} catch {
props.logger.error('Failed to parse request body as JSON')
return
}
const topicResult = webhookTopicSchema.safeParse(json)
if (!topicResult.success) {
props.logger.error(`Failed to validate request body: ${topicResult.error.message}`)
return
}
// We check that the request is actually a topic that can be handle by the handler. This prevent
// from throwing an error because we are not able to parse the payload.
if (!isHandeledTopic(topicResult.data)) {
props.logger.forBot().info(`Event ${topicResult.data} filtered out`)
return
}
const { success, error, data: webhookRequestPayload } = webhookRequestSchema.safeParse(json)
if (!success) {
props.logger.error(`Failed to validate request body: ${error.message}`)
return
}
switch (webhookRequestPayload.topic) {
case 'post.created':
await props.client.createEvent({ type: 'postCreated', payload: webhookRequestPayload })
break
case 'post.updated':
await props.client.createEvent({ type: 'postUpdated', payload: webhookRequestPayload })
break
case 'post.deleted':
await props.client.createEvent({ type: 'postDeleted', payload: webhookRequestPayload })
break
case 'post.voted':
await props.client.createEvent({ type: 'postVoted', payload: webhookRequestPayload })
break
case 'comment.created':
await handleIncomingTextMessage(props, webhookRequestPayload)
break
default:
break
}
}
+56
View File
@@ -0,0 +1,56 @@
import { RuntimeError } from '@botpress/client'
import { handleOutgoingTextMessage } from './channels'
import { FeatureBaseClient } from './feature-base-api/client'
import { handler } from './handler'
import * as bp from '.botpress'
export default new bp.Integration({
register: async (props) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
try {
await client.listBoards()
} catch {
throw new RuntimeError('Failed to register the integration.')
}
},
unregister: async () => {},
actions: {
listPosts: async (props) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
const posts = await client.listPosts(props.input)
return posts
},
createPost: async (props) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
return await client.createPost(props.input)
},
updatePost: async (props) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
return await client.updatePost(props.input)
},
deletePost: async (props) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
return await client.deletePost(props.input)
},
listBoards: async (props) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
return await client.listBoards()
},
getBoard: async (props) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
return await client.getBoard(props.input)
},
getComments: async (props) => {
const client = new FeatureBaseClient(props.ctx.configuration.apiKey)
return await client.getComments(props.input)
},
},
channels: {
comments: {
messages: {
text: handleOutgoingTextMessage,
},
},
},
handler,
})
+8
View File
@@ -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