chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,
|
||||
])
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user