From e823a73b85261a22bd7d3736f03171bad521a3ba Mon Sep 17 00:00:00 2001 From: Mathieu Faucher <99497774+Math-Fauch@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:07:58 -0400 Subject: [PATCH 1/2] fix(webflow): Making sure 1 webhook is used (#14216) Co-authored-by: Mathieu Faucher --- .../webflow/definitions/sub-schemas.ts | 9 +--- integrations/webflow/src/client.ts | 39 ++++++------------ integrations/webflow/src/index.ts | 41 +++++++++++++++++-- 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/integrations/webflow/definitions/sub-schemas.ts b/integrations/webflow/definitions/sub-schemas.ts index 1a0269031c6..cd6d4063aae 100644 --- a/integrations/webflow/definitions/sub-schemas.ts +++ b/integrations/webflow/definitions/sub-schemas.ts @@ -3,14 +3,7 @@ import { z } from '@botpress/sdk' export const formSchema = z.object({ name: z.string().optional(), siteId: z.string(), - data: z - .object({ - 'First Name': z.string().optional(), - 'Last Name': z.string().optional(), - email: z.string().optional(), - 'Phone Number': z.number().optional(), - }) - .optional(), + data: z.record(z.string()).optional(), schema: z .array( z.object({ diff --git a/integrations/webflow/src/client.ts b/integrations/webflow/src/client.ts index 9278e0472b0..53af36be16a 100644 --- a/integrations/webflow/src/client.ts +++ b/integrations/webflow/src/client.ts @@ -107,36 +107,21 @@ export class WebflowClient { await this._axiosClient.delete(path) return {} } + public listWebhooks = async (siteID: string): Promise<{ triggerType: string; id: string }[]> => { + const path = `/sites/${siteID}/webhooks` + const { data } = await this._axiosClient.get<{ webhooks: { triggerType: string; id: string }[] }>(path) + return data.webhooks + } - public createWebhook = async (siteID: string, webhookUrl: string): Promise => { + public createWebhook = async (triggerType: string, siteID: string, webhookUrl: string): Promise => { const path = `/sites/${siteID}/webhooks` - const triggerTypes = [ - 'form_submission', - 'site_publish', - 'page_created', - 'page_metadata_updated', - 'page_deleted', - 'collection_item_created', - 'collection_item_changed', - 'collection_item_deleted', - 'collection_item_published', - 'collection_item_unpublished', - 'comment_created', - ] - for (const triggerType of triggerTypes) { - await this._axiosClient.post(path, { - triggerType, - url: webhookUrl, - }) - } + await this._axiosClient.post(path, { + triggerType, + url: webhookUrl, + }) } - public deleteWebhooks = async (siteID: string): Promise => { - const listPath = `/sites/${siteID}/webhooks` - const listResp = await this._axiosClient.get(listPath) - const webhookIDs = listResp.data.webhooks.map((w: { id: string }) => w.id) - for (const webhookID of webhookIDs) { - await this._axiosClient.delete(`/webhooks/${webhookID}`) - } + public deleteWebhooks = async (webhookID: string): Promise => { + await this._axiosClient.delete(`/webhooks/${webhookID}`) } } diff --git a/integrations/webflow/src/index.ts b/integrations/webflow/src/index.ts index 79c58f56a37..70431c7bf40 100644 --- a/integrations/webflow/src/index.ts +++ b/integrations/webflow/src/index.ts @@ -32,13 +32,45 @@ const fireEvent = async ( export default new bp.Integration({ register: async (props) => { const client = new WebflowClient(props.ctx.configuration.apiToken) - await client - .createWebhook(props.ctx.configuration.siteID, props.webhookUrl) - .catch(_handleError('Failed to create webhooks')) + const triggerTypesToHook = [ + 'form_submission', + 'site_publish', + 'page_created', + 'page_metadata_updated', + 'page_deleted', + 'collection_item_created', + 'collection_item_changed', + 'collection_item_deleted', + 'collection_item_published', + 'collection_item_unpublished', + 'comment_created', + ] + + const already = await client + .listWebhooks(props.ctx.configuration.siteID) + .catch(_handleError('Failed to list webhooks')) + + const existing = new Set(already.map((w: { triggerType: string }) => w.triggerType)) + const missing = triggerTypesToHook.filter((t) => !existing.has(t)) + await Promise.all( + missing.map((triggerType) => + client + .createWebhook(triggerType, props.ctx.configuration.siteID, props.webhookUrl) + .catch(_handleError('Failed to create webhooks')) + ) + ) }, unregister: async (props) => { const client = new WebflowClient(props.ctx.configuration.apiToken) - await client.deleteWebhooks(props.ctx.configuration.siteID).catch(_handleError('Failed to delete webhooks')) + + const webhooks = await client + .listWebhooks(props.ctx.configuration.siteID) + .catch(_handleError('Failed to create webhooks')) + + const webhookIDs = webhooks.map((w: { id: string }) => w.id) + await Promise.all( + webhookIDs.map((webhookID) => client.deleteWebhooks(webhookID).catch(_handleError('Failed to delete webhook'))) + ) }, actions: { async listCollections(props) { @@ -139,6 +171,7 @@ export default new bp.Integration({ switch (data.triggerType) { case 'form_submission': + console.log(data.payload) await fireEvent(props, 'formSubmission', data.payload) break case 'site_publish': From aa0b9aafd4924d83262ead8d5c225eb3e1850839 Mon Sep 17 00:00:00 2001 From: TJ Klint Date: Tue, 9 Sep 2025 10:40:59 -0400 Subject: [PATCH 2/2] feat(integrations/canny): added canny integration (#14194) --- integrations/canny/hub.md | 59 +++ integrations/canny/icon.svg | 1 + integrations/canny/integration.definition.ts | 404 +++++++++++++++++++ integrations/canny/package.json | 15 + integrations/canny/src/actions/boards.ts | 49 +++ integrations/canny/src/actions/comments.ts | 133 ++++++ integrations/canny/src/actions/posts.ts | 207 ++++++++++ integrations/canny/src/actions/users.ts | 111 +++++ integrations/canny/src/channels/posts.ts | 58 +++ integrations/canny/src/handlers/webhook.ts | 94 +++++ integrations/canny/src/index.ts | 55 +++ integrations/canny/src/misc/canny-client.ts | 291 +++++++++++++ integrations/canny/src/misc/errors.ts | 32 ++ integrations/canny/tsconfig.json | 10 + pnpm-lock.yaml | 12 + 15 files changed, 1531 insertions(+) create mode 100644 integrations/canny/hub.md create mode 100644 integrations/canny/icon.svg create mode 100644 integrations/canny/integration.definition.ts create mode 100644 integrations/canny/package.json create mode 100644 integrations/canny/src/actions/boards.ts create mode 100644 integrations/canny/src/actions/comments.ts create mode 100644 integrations/canny/src/actions/posts.ts create mode 100644 integrations/canny/src/actions/users.ts create mode 100644 integrations/canny/src/channels/posts.ts create mode 100644 integrations/canny/src/handlers/webhook.ts create mode 100644 integrations/canny/src/index.ts create mode 100644 integrations/canny/src/misc/canny-client.ts create mode 100644 integrations/canny/src/misc/errors.ts create mode 100644 integrations/canny/tsconfig.json diff --git a/integrations/canny/hub.md b/integrations/canny/hub.md new file mode 100644 index 00000000000..9023191b734 --- /dev/null +++ b/integrations/canny/hub.md @@ -0,0 +1,59 @@ +This integration provides comprehensive access to Canny's API for managing posts and comments in your feature request boards. + +# Features + +## Actions + +**Post Management:** + +- **Create Post**: Create new posts in your Canny boards +- **Get Post**: Retrieve detailed information about a specific post +- **List Posts**: List posts with filtering and pagination options +- **Update Post**: Modify existing posts (title, details, ETA, etc.) +- **Delete Post**: Remove posts from your boards + +**Comment Management:** + +- **Create Comment**: Add comments to posts +- **Get Comment**: Retrieve comment details +- **List Comments**: List comments with filtering options +- **Delete Comment**: Remove comments + +## Channels + +**Posts Channel:** + +- Handles conversations where posts are the main topic and comments are replies +- When users send messages in conversations, they're automatically created as comments on the associated post + +## Events + +**Webhook Support:** + +- Automatically creates conversations when new posts are created in Canny +- Adds messages to conversations when new comments are posted +- The first message in each conversation is the post content, and subsequent messages are comments + +# Configuration + +To use this integration, you'll need: + +1. **API Key**: Your Canny API key (get this from your Canny account settings) + +# Author ID + +Posts and comments will appear as "Botpress" unless you provide a specific `authorId`. + +To use a different author, provide an `authorId` of an existing user in your Canny workspace. You can find available user Ids using the "List Users" action. + +# Usage Examples + +## Setting up Webhooks + +Configure webhooks in your Canny account to point to your Botpress integration endpoint to automatically sync posts and comments as conversations. + +# Conversation Flow + +1. **Post Creation**: When a new post is created in Canny, a new conversation is started in Botpress +2. **Comment Replies**: When users reply in the conversation, new comments are created on the original post +3. **Thread Management**: Each post becomes a conversation thread with comments as messages diff --git a/integrations/canny/icon.svg b/integrations/canny/icon.svg new file mode 100644 index 00000000000..7990248f3b0 --- /dev/null +++ b/integrations/canny/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/integrations/canny/integration.definition.ts b/integrations/canny/integration.definition.ts new file mode 100644 index 00000000000..7d3ec2b0614 --- /dev/null +++ b/integrations/canny/integration.definition.ts @@ -0,0 +1,404 @@ +import { z, IntegrationDefinition } from '@botpress/sdk' + +export default new IntegrationDefinition({ + name: 'canny', + version: '0.1.0', + title: 'Canny', + description: 'Connect your Botpress bot to Canny for feature request management and customer feedback collection', + readme: 'hub.md', + icon: 'icon.svg', + configuration: { + schema: z.object({ + apiKey: z.string().title('API Key').describe('Your Canny API key'), + defaultAuthorId: z.string().title('Default Author Id').describe('Default author Id for system messages'), + }), + }, + channels: { + posts: { + title: 'Posts', + description: 'Channel for Canny posts and comments', + messages: { + text: { + schema: z.object({ + text: z.string(), + }), + }, + image: { + schema: z.object({ + imageUrl: z.string(), + caption: z.string().optional(), + }), + }, + }, + conversation: { + tags: { + cannyPostId: { + title: 'Post ID', + description: 'The Canny post ID', + }, + cannyBoardId: { + title: 'Board ID', + description: 'The Canny board ID', + }, + }, + }, + message: { + tags: { + cannyType: { + title: 'Type', + description: 'Whether this is a post or comment', + }, + cannyPostId: { + title: 'Post ID', + description: 'The associated post ID', + }, + cannyCommentId: { + title: 'Comment ID', + description: 'The comment ID (if applicable)', + }, + }, + }, + }, + }, + actions: { + // Post actions + createPost: { + title: 'Create Post', + description: 'Create a new post in Canny', + input: { + schema: z.object({ + authorId: z + .string() + .optional() + .title('Author Id') + .describe('The author Id (defaults to Botpress user if not provided)'), + boardId: z.string().title('Board Id').describe('The board Id'), + title: z.string().title('Post Title').describe('Post title'), + details: z.string().title('Post Details').describe('Post details'), + byId: z.string().optional().title('By Id').describe('Admin Id who created the post'), + categoryId: z.string().optional().title('Category Id').describe('Category Id'), + ownerId: z.string().optional().title('Owner Id').describe('Owner Id'), + imageURLs: z.array(z.string()).optional().title('Image URLs').describe('Image URLs'), + eta: z.string().optional().title('ETA').describe('ETA (MM/YYYY)'), + etaPublic: z.boolean().optional().title('ETA Public').describe('Make ETA public'), + customFields: z.record(z.any()).optional().title('Custom Fields').describe('Custom fields'), + }), + }, + output: { + schema: z.object({ + postId: z.string().title('Post ID').describe('The ID of the created post'), + }), + }, + }, + getPost: { + title: 'Get Post', + description: 'Retrieve a post by ID', + input: { + schema: z.object({ + postId: z.string().title('Post Id').describe('The post Id'), + boardId: z.string().optional().title('Board Id').describe('The board Id'), + }), + }, + output: { + schema: z.object({ + post: z + .object({ + id: z.string(), + title: z.string(), + details: z.string().optional(), + authorName: z.string().optional(), + authorEmail: z.string().optional(), + boardName: z.string(), + status: z.string(), + score: z.number(), + commentCount: z.number(), + created: z.string(), + url: z.string(), + }) + .title('Post') + .describe('The retrieved post object'), + }), + }, + }, + listPosts: { + title: 'List Posts', + description: 'List posts with optional filters', + input: { + schema: z.object({ + boardId: z.string().optional().title('Board Id').describe('Filter posts by board Id'), + authorId: z.string().optional().title('Author Id').describe('Filter posts by author Id'), + companyId: z.string().optional().title('Company Id').describe('Filter posts by company Id'), + tagIds: z.array(z.string()).optional().title('Tag Ids').describe('Filter posts by tag Ids'), + limit: z.number().optional().title('Limit').describe('Number of posts to return'), + nextToken: z.number().optional().title('Next Token').describe('Token for pagination - skip this many posts'), + search: z.string().optional().title('Search').describe('Search term to filter posts'), + sort: z + .enum(['newest', 'oldest', 'relevance', 'score', 'statusChanged', 'trending']) + .optional() + .title('Sort') + .describe('Sort order for posts'), + status: z.string().optional().title('Status').describe('Filter posts by status'), + }), + }, + output: { + schema: z.object({ + posts: z + .array( + z.object({ + id: z.string(), + title: z.string(), + details: z.string().optional(), + authorName: z.string().optional(), + authorEmail: z.string().optional(), + boardName: z.string(), + status: z.string(), + score: z.number(), + commentCount: z.number(), + created: z.string(), + url: z.string(), + }) + ) + .title('Posts') + .describe('Array of posts'), + nextToken: z.number().optional().title('Next Token').describe('Token for next page if more posts available'), + }), + }, + }, + updatePost: { + title: 'Update Post', + description: 'Update an existing post', + input: { + schema: z.object({ + postId: z.string().title('Post Id').describe('The post Id'), + title: z.string().optional().title('Title').describe('Updated post title'), + details: z.string().optional().title('Details').describe('Updated post details'), + eta: z.string().optional().title('ETA').describe('Updated ETA (MM/YYYY)'), + etaPublic: z.boolean().optional().title('ETA Public').describe('Make ETA public'), + imageURLs: z.array(z.string()).optional().title('Image URLs').describe('Updated image URLs'), + customFields: z.record(z.any()).optional().title('Custom Fields').describe('Updated custom fields'), + }), + }, + output: { + schema: z.object({}), + }, + }, + deletePost: { + title: 'Delete Post', + description: 'Delete a post', + input: { + schema: z.object({ + postId: z.string().title('Post Id').describe('The post Id to delete'), + }), + }, + output: { + schema: z.object({}), + }, + }, + + // Comment actions + createComment: { + title: 'Create Comment', + description: 'Create a new comment on a post', + input: { + schema: z.object({ + authorId: z + .string() + .optional() + .title('Author Id') + .describe('The author Id (defaults to Botpress user if not provided)'), + postId: z.string().title('Post Id').describe('The post Id'), + value: z.string().title('Comment Text').describe('Comment text'), + parentId: z.string().optional().title('Parent Id').describe('Parent comment Id for replies'), + imageURLs: z.array(z.string()).optional().title('Image URLs').describe('Image URLs'), + internal: z.boolean().optional().title('Internal').describe('Whether this is an internal comment'), + shouldNotifyVoters: z.boolean().optional().title('Notify Voters').describe('Notify voters'), + createdAt: z.string().optional().title('Created At').describe('Created timestamp'), + }), + }, + output: { + schema: z.object({ + commentId: z.string().title('Comment ID').describe('The ID of the created comment'), + }), + }, + }, + getComment: { + title: 'Get Comment', + description: 'Retrieve a comment by ID', + input: { + schema: z.object({ + commentId: z.string().title('Comment Id').describe('The comment Id'), + }), + }, + output: { + schema: z.object({ + comment: z + .object({ + id: z.string(), + value: z.string(), + authorName: z.string(), + authorEmail: z.string(), + postTitle: z.string(), + postId: z.string(), + created: z.string(), + internal: z.boolean(), + likeCount: z.number(), + }) + .title('Comment') + .describe('The retrieved comment object'), + }), + }, + }, + listComments: { + title: 'List Comments', + description: 'List comments with optional filters', + input: { + schema: z.object({ + postId: z.string().optional().title('Post Id').describe('Filter comments by post Id'), + authorId: z.string().optional().title('Author Id').describe('Filter comments by author Id'), + boardId: z.string().optional().title('Board Id').describe('Filter comments by board Id'), + companyId: z.string().optional().title('Company Id').describe('Filter comments by company Id'), + limit: z.number().optional().title('Limit').describe('Number of comments to return'), + nextToken: z + .number() + .optional() + .title('Next Token') + .describe('Token for pagination - skip this many comments'), + }), + }, + output: { + schema: z.object({ + comments: z + .array( + z.object({ + id: z.string(), + value: z.string(), + authorName: z.string(), + authorEmail: z.string(), + postTitle: z.string(), + postId: z.string(), + created: z.string(), + internal: z.boolean(), + likeCount: z.number(), + }) + ) + .title('Comments') + .describe('Array of comments'), + nextToken: z + .number() + .optional() + .title('Next Token') + .describe('Token for next page if more comments available'), + }), + }, + }, + deleteComment: { + title: 'Delete Comment', + description: 'Delete a comment', + input: { + schema: z.object({ + commentId: z.string().title('Comment Id').describe('The comment Id to delete'), + }), + }, + output: { + schema: z.object({}), + }, + }, + + // User management actions + createOrUpdateUser: { + title: 'Create or Update User', + description: 'Create a new user or update an existing one in Canny', + input: { + schema: z.object({ + name: z.string().min(1).max(50).title('Name').describe('User display name (required, 1-50 characters)'), + userId: z.string().optional().title('User Id').describe('Your internal user Id'), + email: z.string().optional().title('Email').describe('User email address'), + avatarURL: z.string().optional().title('Avatar URL').describe('User avatar URL'), + alias: z.string().optional().title('Alias').describe('User alias'), + created: z.string().optional().title('Created').describe('User creation timestamp'), + customFields: z.record(z.any()).optional().title('Custom Fields').describe('Custom fields'), + }), + }, + output: { + schema: z.object({ + user: z + .object({ + id: z.string(), + name: z.string(), + email: z.string(), + avatarURL: z.string().optional(), + userId: z.string(), + isAdmin: z.boolean(), + created: z.string(), + }) + .title('User') + .describe('The created or updated user object'), + }), + }, + }, + listUsers: { + title: 'List Users', + description: 'List all users in your Canny workspace with pagination', + input: { + schema: z.object({ + limit: z + .number() + .min(1) + .max(100) + .optional() + .title('Limit') + .describe('Number of users to fetch (1-100, defaults to 10)'), + nextToken: z.string().optional().title('Next Token').describe('Token for pagination from previous request'), + }), + }, + output: { + schema: z.object({ + users: z + .array( + z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + avatarURL: z.string().optional(), + userId: z.string(), // Can be empty string if user has no userId + isAdmin: z.boolean(), + created: z.string(), + }) + ) + .title('Users') + .describe('Array of users'), + nextToken: z.string().optional().title('Next Token').describe('Token for next page if more users available'), + }), + }, + }, + + // Board management actions + listBoards: { + title: 'List Boards', + description: 'List all boards in your Canny workspace', + input: { + schema: z.object({ + limit: z.number().optional().title('Limit').describe('Number of boards to fetch'), + nextToken: z.number().optional().title('Next Token').describe('Token for pagination - skip this many boards'), + }), + }, + output: { + schema: z.object({ + boards: z + .array( + z.object({ + id: z.string(), + name: z.string(), + postCount: z.number(), + url: z.string(), + created: z.string(), + }) + ) + .title('Boards') + .describe('Array of boards'), + nextToken: z.number().optional().title('Next Token').describe('Token for next page if more boards available'), + }), + }, + }, + }, +}) diff --git a/integrations/canny/package.json b/integrations/canny/package.json new file mode 100644 index 00000000000..d8082a1cb4d --- /dev/null +++ b/integrations/canny/package.json @@ -0,0 +1,15 @@ +{ + "name": "@botpresshub/canny", + "description": "Canny integration for Botpress", + "private": true, + "scripts": { + "build": "bp add -y && bp build", + "check:type": "tsc --noEmit", + "check:bplint": "bp lint" + }, + "dependencies": { + "@botpress/client": "workspace:*", + "@botpress/sdk": "workspace:*", + "axios": "^1.6.0" + } +} diff --git a/integrations/canny/src/actions/boards.ts b/integrations/canny/src/actions/boards.ts new file mode 100644 index 00000000000..1508a872eaa --- /dev/null +++ b/integrations/canny/src/actions/boards.ts @@ -0,0 +1,49 @@ +import { CannyClient } from '../misc/canny-client' +import { InvalidAPIKeyError, CannyAPIError } from '../misc/errors' +import { IntegrationProps } from '.botpress' + +type ListBoardsAction = IntegrationProps['actions']['listBoards'] + +const DEFAULT_BOARD_LIMIT = 25 + +export const listBoards: ListBoardsAction = async ({ input, ctx }) => { + if (!ctx.configuration.apiKey) { + throw new InvalidAPIKeyError() + } + + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + try { + const result = await client.listBoards({ + limit: input.limit, + skip: input.nextToken, + }) + + const boards = result.boards || [] + const requestedLimit = input.limit || DEFAULT_BOARD_LIMIT + const hasMore = result.hasMore !== undefined ? result.hasMore : boards.length >= requestedLimit + + const nextToken = hasMore ? (input.nextToken ?? 0) + (input.limit ?? DEFAULT_BOARD_LIMIT) : undefined + + return { + boards: boards.map((board) => ({ + id: board.id, + name: board.name, + postCount: board.postCount, + url: board.url, + created: board.created, + })), + nextToken, + } + } catch (error: any) { + console.error('Error listing boards:', error) + + if (error.response?.status === 401) { + throw new InvalidAPIKeyError() + } + + throw new CannyAPIError(error) + } +} diff --git a/integrations/canny/src/actions/comments.ts b/integrations/canny/src/actions/comments.ts new file mode 100644 index 00000000000..61ef2dd4afb --- /dev/null +++ b/integrations/canny/src/actions/comments.ts @@ -0,0 +1,133 @@ +import { RuntimeError } from '@botpress/sdk' +import { CannyClient } from '../misc/canny-client' +import { InvalidAPIKeyError, CannyAPIError } from '../misc/errors' +import { IntegrationProps } from '.botpress' + +type CreateCommentAction = IntegrationProps['actions']['createComment'] +type GetCommentAction = IntegrationProps['actions']['getComment'] +type ListCommentsAction = IntegrationProps['actions']['listComments'] +type DeleteCommentAction = IntegrationProps['actions']['deleteComment'] + +export const createComment: CreateCommentAction = async ({ input, ctx }) => { + if (!ctx.configuration.apiKey) { + throw new InvalidAPIKeyError() + } + + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + let authorId = input.authorId + + if (!authorId) { + // Create the Botpress user for comments + const botUser = await client.createOrUpdateUser({ + name: 'Botpress', + userId: 'botpress-user', + email: 'integration@botpress.com', + }) + authorId = botUser.id + } + if (!input.postId) { + throw new RuntimeError('postId is required to create a comment') + } + if (!input.value) { + throw new RuntimeError('value (comment text) is required to create a comment') + } + + try { + const result = await client.createComment({ + authorId, + postId: input.postId, + value: input.value, + parentId: input.parentId, + imageURLs: input.imageURLs, + internal: input.internal, + shouldNotifyVoters: input.shouldNotifyVoters, + createdAt: input.createdAt, + }) + + return { + commentId: result.id, + } + } catch (error: any) { + if ( + error.response?.data?.error?.includes('user') || + error.response?.data?.error?.includes('author') || + error.message?.includes('user') + ) { + if (!input.authorId) { + throw new RuntimeError( + 'Comment creation failed: Canny requires users to be "identified" through their SDK. Please provide an authorId of a user who has been identified in your Canny workspace, or ensure the default author from the integration configuration is properly identified.' + ) + } else { + throw new RuntimeError( + `Comment creation failed: The authorId "${authorId}" is not valid or the user hasn't been identified through Canny's SDK.` + ) + } + } + throw new CannyAPIError(error) + } +} + +export const getComment: GetCommentAction = async ({ input, ctx }) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + const comment = await client.getComment(input.commentId) + + return { + comment: { + id: comment.id, + value: comment.value, + authorName: comment.author.name, + authorEmail: comment.author.email, + postTitle: comment.post.title, + postId: comment.post.id, + created: comment.created, + internal: comment.internal, + likeCount: comment.likeCount, + }, + } +} + +export const listComments: ListCommentsAction = async ({ input, ctx }) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + const result = await client.listComments({ + postId: input.postId, + authorId: input.authorId, + boardId: input.boardId, + companyId: input.companyId, + limit: input.limit, + skip: input.nextToken, + }) + + return { + comments: result.comments.map((comment) => ({ + id: comment.id, + value: comment.value, + authorName: comment.author.name, + authorEmail: comment.author.email, + postTitle: comment.post.title, + postId: comment.post.id, + created: comment.created, + internal: comment.internal, + likeCount: comment.likeCount, + })), + nextToken: result.hasMore ? (input.nextToken || 0) + (input.limit || 10) : undefined, + } +} + +export const deleteComment: DeleteCommentAction = async ({ input, ctx }) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + await client.deleteComment(input.commentId) + + return {} +} diff --git a/integrations/canny/src/actions/posts.ts b/integrations/canny/src/actions/posts.ts new file mode 100644 index 00000000000..f6fe2180b45 --- /dev/null +++ b/integrations/canny/src/actions/posts.ts @@ -0,0 +1,207 @@ +import { RuntimeError } from '@botpress/sdk' +import { CannyClient } from '../misc/canny-client' +import { InvalidAPIKeyError, CannyAPIError } from '../misc/errors' +import { IntegrationProps } from '.botpress' + +type CreatePostAction = IntegrationProps['actions']['createPost'] +type GetPostAction = IntegrationProps['actions']['getPost'] +type ListPostsAction = IntegrationProps['actions']['listPosts'] +type UpdatePostAction = IntegrationProps['actions']['updatePost'] +type DeletePostAction = IntegrationProps['actions']['deletePost'] + +export const createPost: CreatePostAction = async ({ input, ctx }) => { + if (!ctx.configuration.apiKey) { + throw new InvalidAPIKeyError() + } + + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + const authorId = input.authorId + + if (!input.boardId) { + throw new RuntimeError('boardId is required to create a post') + } + + if (!input.title) { + throw new RuntimeError('title is required to create a post') + } + if (!input.details) { + throw new RuntimeError('details is required to create a post') + } + + if (!authorId) { + const botUser = await client.createOrUpdateUser({ + name: 'Botpress', + userId: 'botpress-user', + email: 'integration@botpress.com', + }) + + // Try approach 1: Use the userId field + try { + const result = await client.createPost({ + authorId: 'botpress-user', + boardId: input.boardId, + title: input.title, + details: input.details, + byId: input.byId, + categoryId: input.categoryId, + ownerId: input.ownerId, + imageURLs: input.imageURLs, + eta: input.eta, + etaPublic: input.etaPublic, + customFields: input.customFields, + }) + + return { postId: result.id } + } catch (error1: any) { + // Try approach 2: Use the Canny-generated id + try { + const result = await client.createPost({ + authorId: botUser.id, + boardId: input.boardId, + title: input.title, + details: input.details, + byId: input.byId, + categoryId: input.categoryId, + ownerId: input.ownerId, + imageURLs: input.imageURLs, + eta: input.eta, + etaPublic: input.etaPublic, + customFields: input.customFields, + }) + + return { postId: result.id } + } catch (error2: any) { + throw new RuntimeError( + `Post creation failed: Canny requires users to be "identified" through their SDK. Both approaches failed: userId (${error1.response?.data?.error || error1.message}) and Canny id (${error2.response?.data?.error || error2.message}). Please provide an authorId of a user who has been identified in your Canny workspace!` + ) + } + } + } + + try { + const result = await client.createPost({ + authorId, + boardId: input.boardId, + title: input.title, + details: input.details, + byId: input.byId, + categoryId: input.categoryId, + ownerId: input.ownerId, + imageURLs: input.imageURLs, + eta: input.eta, + etaPublic: input.etaPublic, + customFields: input.customFields, + }) + + return { + postId: result.id, + } + } catch (error: any) { + if ( + error.response?.data?.error?.includes('user') || + error.response?.data?.error?.includes('author') || + error.message?.includes('user') + ) { + if (!input.authorId) { + throw new RuntimeError( + 'Post creation failed: Canny requires users to be "identified" through their SDK. Please provide an authorId of a user who has been identified in your Canny workspace, or ensure the default author from the integration configuration is properly identified.' + ) + } else { + throw new RuntimeError( + `Post creation failed: The authorId "${authorId}" is not valid or the user hasn't been identified through Canny's SDK.` + ) + } + } + throw new CannyAPIError(error) + } +} + +export const getPost: GetPostAction = async ({ input, ctx }) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + const post = await client.getPost(input.postId, input.boardId) + + return { + post: { + id: post.id, + title: post.title, + details: post.details, + authorName: post.author?.name, + authorEmail: post.author?.email, + boardName: post.board.name, + status: post.status, + score: post.score, + commentCount: post.commentCount, + created: post.created, + url: post.url, + }, + } +} + +export const listPosts: ListPostsAction = async ({ input, ctx }) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + const result = await client.listPosts({ + boardId: input.boardId, + authorId: input.authorId, + companyId: input.companyId, + tagIds: input.tagIds, + limit: input.limit, + skip: input.nextToken, + search: input.search, + sort: input.sort, + status: input.status, + }) + + return { + posts: result.posts.map((post) => ({ + id: post.id, + title: post.title, + details: post.details, + authorName: post.author?.name, + authorEmail: post.author?.email, + boardName: post.board.name, + status: post.status, + score: post.score, + commentCount: post.commentCount, + created: post.created, + url: post.url, + })), + nextToken: result.hasMore ? (input.nextToken || 0) + (input.limit || 10) : undefined, + } +} + +export const updatePost: UpdatePostAction = async ({ input, ctx }) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + await client.updatePost({ + postId: input.postId, + title: input.title, + details: input.details, + eta: input.eta, + etaPublic: input.etaPublic, + imageURLs: input.imageURLs, + customFields: input.customFields, + }) + + return {} +} + +export const deletePost: DeletePostAction = async ({ input, ctx }) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + await client.deletePost(input.postId) + + return {} +} diff --git a/integrations/canny/src/actions/users.ts b/integrations/canny/src/actions/users.ts new file mode 100644 index 00000000000..cf957d524cc --- /dev/null +++ b/integrations/canny/src/actions/users.ts @@ -0,0 +1,111 @@ +import { RuntimeError } from '@botpress/sdk' +import { CannyClient } from '../misc/canny-client' +import { InvalidAPIKeyError, InvalidRequestError, CannyAPIError } from '../misc/errors' +import { IntegrationProps } from '.botpress' + +// User action types +type CreateOrUpdateUserAction = IntegrationProps['actions']['createOrUpdateUser'] +type ListUsersAction = IntegrationProps['actions']['listUsers'] + +export const createOrUpdateUser: CreateOrUpdateUserAction = async ({ input, ctx }) => { + if (!ctx.configuration.apiKey) { + throw new InvalidAPIKeyError() + } + + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + if (!input.name || input.name.trim().length === 0) { + throw new RuntimeError('name is required and must be between 1-50 characters') + } + if (input.name.length > 50) { + throw new RuntimeError('name must be 50 characters or less') + } + + try { + const user = await client.createOrUpdateUser({ + name: input.name, + userId: input.userId, + email: input.email, + avatarURL: input.avatarURL, + alias: input.alias, + created: input.created, + customFields: input.customFields, + }) + + return { + user: { + id: user.id, + name: user.name, + email: user.email, + avatarURL: user.url, // Canny returns avatar in 'url' field + userId: user.userId, + isAdmin: user.isAdmin, + created: user.created, + }, + } + } catch (error: any) { + console.error('Error creating/updating user:', error) + + if (error.response?.status === 401) { + throw new InvalidAPIKeyError() + } + + if (error.response?.status === 400) { + throw new InvalidRequestError(error) + } + + if (error.response?.data?.error?.includes('user') || error.message?.includes('user')) { + throw new RuntimeError( + `User identification failed: ${error.response?.data?.error || error.message}. Make sure the user is properly identified through Canny Identify SDK.` + ) + } + + throw new CannyAPIError(error) + } +} + +export const listUsers: ListUsersAction = async ({ input, ctx }) => { + if (!ctx.configuration.apiKey) { + throw new InvalidAPIKeyError() + } + + if (input.limit && (input.limit < 1 || input.limit > 100)) { + throw new RuntimeError('limit must be between 1 and 100') + } + + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + try { + const result = await client.listUsers({ + limit: input.limit, + cursor: input.nextToken, + }) + + const response = { + users: result.users.map((user) => ({ + id: user.id, + name: user.name, + email: user.email || '', + avatarURL: user.url || undefined, + userId: user.userId || '', + isAdmin: user.isAdmin, + created: user.created, + })), + nextToken: result.hasNextPage ? (result as any).cursor : undefined, + } + + return response + } catch (error: any) { + console.error('Error listing users:', error) + + if (error.response?.status === 401) { + throw new InvalidAPIKeyError() + } + + throw new CannyAPIError(error) + } +} diff --git a/integrations/canny/src/channels/posts.ts b/integrations/canny/src/channels/posts.ts new file mode 100644 index 00000000000..43ad2f50a3d --- /dev/null +++ b/integrations/canny/src/channels/posts.ts @@ -0,0 +1,58 @@ +import { CannyClient } from '../misc/canny-client' + +export const posts = { + messages: { + text: async ({ payload, ctx, conversation, ack }: any) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + const postId = conversation.tags?.cannyPostId + if (!postId) { + throw new Error('No post ID found in conversation') + } + + const botUser = await client.createOrUpdateUser({ + name: 'Botpress', + userId: 'botpress-user', + email: 'integration@botpress.com', + }) + const authorId = botUser.id + + await client.createComment({ + authorId, + postId, + value: payload.text, + }) + + await ack() + }, + + image: async ({ payload, ctx, conversation, ack }: any) => { + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + const postId = conversation.tags?.cannyPostId + if (!postId) { + throw new Error('No post ID found in conversation') + } + + const botUser = await client.createOrUpdateUser({ + name: 'Botpress', + userId: 'botpress-user', + email: 'integration@botpress.com', + }) + const authorId = botUser.id + + await client.createComment({ + authorId, + postId, + value: payload.caption || 'Image attached', + imageURLs: [payload.imageUrl], + }) + + await ack() + }, + }, +} diff --git a/integrations/canny/src/handlers/webhook.ts b/integrations/canny/src/handlers/webhook.ts new file mode 100644 index 00000000000..a591b1ce18f --- /dev/null +++ b/integrations/canny/src/handlers/webhook.ts @@ -0,0 +1,94 @@ +import { z } from '@botpress/sdk' +import { IntegrationProps } from '.botpress' + +type WebhookHandler = IntegrationProps['handler'] + +const CannyWebhookPayloadSchema = z.object({ + type: z.enum(['post.created', 'comment.created']), + data: z.any(), +}) + +export const webhook: WebhookHandler = async ({ req, client }) => { + if (req.method !== 'POST') { + return { status: 405, body: 'Method not allowed' } + } + + try { + const payload = CannyWebhookPayloadSchema.parse(req.body) + + switch (payload.type) { + case 'post.created': + await handlePostCreated(client, payload.data) + break + + case 'comment.created': + await handleCommentCreated(client, payload.data) + break + + default: + } + + return { status: 200, body: 'OK' } + } catch (error) { + console.error('Webhook processing error:', error) + return { status: 500, body: 'Internal server error' } + } +} + +async function handlePostCreated(client: Parameters[0]['client'], postData: any) { + try { + const { conversation } = await client.createConversation({ + channel: 'posts', + tags: { + cannyPostId: postData.id, + cannyBoardId: postData.board.id, + }, + }) + + await client.createMessage({ + conversationId: conversation.id, + type: 'text', + payload: { + text: `**${postData.title}**\n\n${postData.details || ''}\n\n*Posted by ${postData.author?.name || 'Unknown'}*`, + }, + userId: postData.author?.id || 'canny-system', + tags: { + cannyType: 'post', + cannyPostId: postData.id, + }, + }) + } catch (error) { + console.error('Error handling post creation:', error) + } +} + +async function handleCommentCreated(client: Parameters[0]['client'], commentData: any) { + try { + const { conversations } = await client.listConversations({ + tags: { + cannyPostId: commentData.post.id, + }, + }) + + const conversation = conversations[0] + if (!conversation) { + return + } + + await client.createMessage({ + conversationId: conversation.id, + type: 'text', + payload: { + text: commentData.value, + }, + userId: commentData.author.id, + tags: { + cannyType: 'comment', + cannyCommentId: commentData.id, + cannyPostId: commentData.post.id, + }, + }) + } catch (error) { + console.error('Error handling comment creation:', error) + } +} diff --git a/integrations/canny/src/index.ts b/integrations/canny/src/index.ts new file mode 100644 index 00000000000..ce8dc5b8df9 --- /dev/null +++ b/integrations/canny/src/index.ts @@ -0,0 +1,55 @@ +import { listBoards } from './actions/boards' +import { createComment, getComment, listComments, deleteComment } from './actions/comments' +import { createPost, getPost, listPosts, updatePost, deletePost } from './actions/posts' + +import { createOrUpdateUser, listUsers } from './actions/users' + +import { posts } from './channels/posts' +import { webhook } from './handlers/webhook' +import { CannyClient } from './misc/canny-client' + +import * as bp from '.botpress' + +export default new bp.Integration({ + register: async ({ ctx }) => { + try { + if (!ctx.configuration.apiKey) { + throw new Error('Canny API key is not configured') + } + + const client = CannyClient.create({ + apiKey: ctx.configuration.apiKey, + }) + + if (!ctx.configuration.defaultAuthorId) { + throw new Error('Default author ID is required in integration configuration.') + } + + const boardsResult = await client.listBoards() + + boardsResult.boards.forEach((_board) => {}) + } catch (error) { + console.error('Failed to register Canny integration:', error) + throw error + } + }, + unregister: async () => {}, + actions: { + createPost, + getPost, + listPosts, + updatePost, + deletePost, + createComment, + getComment, + listComments, + deleteComment, + createOrUpdateUser, + listUsers, + listBoards, + }, + channels: { + posts, + }, + handler: webhook, +}) diff --git a/integrations/canny/src/misc/canny-client.ts b/integrations/canny/src/misc/canny-client.ts new file mode 100644 index 00000000000..0e3625ee36a --- /dev/null +++ b/integrations/canny/src/misc/canny-client.ts @@ -0,0 +1,291 @@ +import axios, { Axios } from 'axios' + +export type CannyClientConfiguration = { + apiKey: string +} + +export type User = { + id: string + created: string + email: string + isAdmin: boolean + name: string + url: string + userId: string +} + +export type Board = { + id: string + created: string + name: string + postCount: number + url: string +} + +export type Category = { + id: string + name: string + postCount: number + url: string + parentId?: string +} + +export type Tag = { + id: string + name: string + postCount: number + url: string +} + +export type Post = { + id: string + author: User | null + board: Board + by?: User + category?: Category + commentCount: number + created: string + details?: string + eta?: string + imageURLs: string[] + jira?: { + linkedIssues: Array<{ + id: string + key: string + url: string + }> + } + linear?: { + linkedIssueIds: string[] + } + owner?: User + score: number + status: string + statusChangedAt?: string + tags: Tag[] + title: string + url: string +} + +export type Comment = { + id: string + author: User + board: Board + created: string + imageURLs: string[] + internal: boolean + likeCount: number + mentions: User[] + parentId?: string + post: Post + private: boolean + reactions: Record + value: string +} + +export type CreatePostRequest = { + authorId: string + boardId: string + byId?: string + categoryId?: string + details: string + title: string + ownerId?: string + imageURLs?: string[] + createdAt?: string + eta?: string + etaPublic?: boolean + customFields?: Record +} + +export type UpdatePostRequest = { + postId: string + title?: string + details?: string + eta?: string + etaPublic?: boolean + imageURLs?: string[] + customFields?: Record +} + +export type CreateCommentRequest = { + authorId: string + postId: string + value: string + parentId?: string + imageURLs?: string[] + internal?: boolean + shouldNotifyVoters?: boolean + createdAt?: string +} + +export type ListPostsRequest = { + boardId?: string + authorId?: string + companyId?: string + tagIds?: string[] + limit?: number + skip?: number + search?: string + sort?: 'newest' | 'oldest' | 'relevance' | 'score' | 'statusChanged' | 'trending' + status?: string +} + +export type ListCommentsRequest = { + postId?: string + authorId?: string + boardId?: string + companyId?: string + limit?: number + skip?: number +} + +export type ListPostsResponse = { + posts: Post[] + hasMore: boolean +} + +export type ListCommentsResponse = { + comments: Comment[] + hasMore: boolean +} + +export type CreateUserRequest = { + name: string + userId?: string + email?: string + avatarURL?: string + alias?: string + created?: string + customFields?: Record +} + +export type ListUsersRequest = { + limit?: number + cursor?: string +} + +export type ListUsersResponse = { + users: User[] + hasNextPage: boolean + cursor?: string +} + +export type ListBoardsRequest = { + limit?: number + skip?: number +} + +export type ListBoardsResponse = { + boards: Board[] + hasMore: boolean +} + +export class CannyClient { + private constructor( + private readonly _client: Axios, + private readonly _apiKey: string + ) {} + + public static create(config: CannyClientConfiguration) { + const client = axios.create({ + baseURL: 'https://canny.io/api/v1', + timeout: 10_000, + headers: { + 'Content-Type': 'application/json', + }, + transformRequest: [ + (data) => { + return JSON.stringify({ ...data, apiKey: config.apiKey }) + }, + ], + }) + + return new CannyClient(client, config.apiKey) + } + + public async createPost(request: CreatePostRequest): Promise<{ id: string }> { + // Convert Id fields to ID for Canny API + const apiRequest = { + authorID: request.authorId, + boardID: request.boardId, + byID: request.byId, + categoryID: request.categoryId, + ownerID: request.ownerId, + details: request.details, + title: request.title, + imageURLs: request.imageURLs, + createdAt: request.createdAt, + eta: request.eta, + etaPublic: request.etaPublic, + customFields: request.customFields, + } + const response = await this._client.post('/posts/create', apiRequest) + return response.data + } + + public async getPost(id: string, boardId?: string): Promise { + const response = await this._client.post('/posts/retrieve', { id, boardId }) + return response.data + } + + public async listPosts(request: ListPostsRequest = {}): Promise { + const response = await this._client.post('/posts/list', request) + return response.data + } + + public async updatePost(request: UpdatePostRequest): Promise<{ success: boolean }> { + const response = await this._client.post('/posts/update', request) + return { success: response.data === 'success' } + } + + public async deletePost(postId: string): Promise<{ success: boolean }> { + const response = await this._client.post('/posts/delete', { postId }) + return { success: response.data === 'success' } + } + + public async createComment(request: CreateCommentRequest): Promise<{ id: string }> { + const apiRequest = { + authorID: request.authorId, + postID: request.postId, + value: request.value, + parentID: request.parentId, + imageURLs: request.imageURLs, + internal: request.internal, + shouldNotifyVoters: request.shouldNotifyVoters, + createdAt: request.createdAt, + } + const response = await this._client.post('/comments/create', apiRequest) + return response.data + } + + public async getComment(id: string): Promise { + const response = await this._client.post('/comments/retrieve', { id }) + return response.data + } + + public async listComments(request: ListCommentsRequest = {}): Promise { + const response = await this._client.post('/comments/list', request) + return response.data + } + + public async deleteComment(commentId: string): Promise<{ success: boolean }> { + const response = await this._client.post('/comments/delete', { commentId }) + return { success: response.data === 'success' } + } + + public async createOrUpdateUser(request: CreateUserRequest): Promise { + const response = await this._client.post('/users/create_or_update', request) + return response.data + } + + public async listUsers(request: ListUsersRequest = {}): Promise { + const response = await this._client.post('https://canny.io/api/v2/users/list', request) + return response.data + } + + public async listBoards(request: ListBoardsRequest = {}): Promise { + const response = await this._client.post('/boards/list', request) + return response.data + } +} diff --git a/integrations/canny/src/misc/errors.ts b/integrations/canny/src/misc/errors.ts new file mode 100644 index 00000000000..6e1479a74b8 --- /dev/null +++ b/integrations/canny/src/misc/errors.ts @@ -0,0 +1,32 @@ +import { RuntimeError } from '@botpress/sdk' +import { isAxiosError } from 'axios' + +export class InvalidAPIKeyError extends RuntimeError { + public constructor() { + super('Invalid Canny API key. Please check your API key in the integration settings.') + } +} + +export class InvalidRequestError extends RuntimeError { + public constructor(thrown: unknown) { + let message: string + if (isAxiosError(thrown)) { + message = thrown.response?.data?.error || thrown.message || 'Unknown error' + } else { + message = thrown instanceof Error ? thrown.message : String(thrown) + } + super(`Invalid request: ${message}`) + } +} + +export class CannyAPIError extends RuntimeError { + public constructor(thrown: unknown) { + let message: string + if (isAxiosError(thrown)) { + message = thrown.response?.data?.error || thrown.message || 'Unknown error' + } else { + message = thrown instanceof Error ? thrown.message : String(thrown) + } + super(`Canny API error: ${message}`) + } +} diff --git a/integrations/canny/tsconfig.json b/integrations/canny/tsconfig.json new file mode 100644 index 00000000000..c998946e1dd --- /dev/null +++ b/integrations/canny/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "integration.definition.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed6d767790..ce4dd892460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -493,6 +493,18 @@ importers: specifier: ^1.11.0 version: 1.11.0 + integrations/canny: + dependencies: + '@botpress/client': + specifier: workspace:* + version: link:../../packages/client + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + axios: + specifier: ^1.6.0 + version: 1.11.0 + integrations/cerebras: dependencies: '@botpress/client':