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
+59
View File
@@ -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
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 85 KiB

@@ -0,0 +1,408 @@
import { z, IntegrationDefinition } from '@botpress/sdk'
export default new IntegrationDefinition({
name: 'canny',
version: '0.2.3',
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'),
}),
},
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('ID').describe('Post ID'),
title: z.string().title('Title').describe('Post title'),
details: z.string().optional().title('Details').describe('Post details'),
authorName: z.string().optional().title('Author Name').describe('Name of the post author'),
authorEmail: z.string().optional().title('Author Email').describe('Email of the post author'),
boardName: z.string().title('Board Name').describe('Name of the board'),
status: z.string().title('Status').describe('Post status'),
score: z.number().title('Score').describe('Post score'),
commentCount: z.number().title('Comment Count').describe('Number of comments'),
created: z.string().title('Created').describe('Creation timestamp'),
url: z.string().title('URL').describe('Post URL'),
})
.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('ID').describe('Post ID'),
title: z.string().title('Title').describe('Post title'),
details: z.string().optional().title('Details').describe('Post details'),
authorName: z.string().optional().title('Author Name').describe('Name of the post author'),
authorEmail: z.string().optional().title('Author Email').describe('Email of the post author'),
boardName: z.string().title('Board Name').describe('Name of the board'),
status: z.string().title('Status').describe('Post status'),
score: z.number().title('Score').describe('Post score'),
commentCount: z.number().title('Comment Count').describe('Number of comments'),
created: z.string().title('Created').describe('Creation timestamp'),
url: z.string().title('URL').describe('Post URL'),
})
)
.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().title('ID').describe('Comment ID'),
value: z.string().title('Value').describe('Comment text'),
authorName: z.string().title('Author Name').describe('Name of the comment author'),
authorEmail: z.string().title('Author Email').describe('Email of the comment author'),
postTitle: z.string().title('Post Title').describe('Title of the associated post'),
postId: z.string().title('Post ID').describe('ID of the associated post'),
created: z.string().title('Created').describe('Creation timestamp'),
internal: z.boolean().title('Internal').describe('Whether this is an internal comment'),
likeCount: z.number().title('Like Count').describe('Number of likes'),
})
.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().title('ID').describe('Comment ID'),
value: z.string().title('Value').describe('Comment text'),
authorName: z.string().title('Author Name').describe('Name of the comment author'),
authorEmail: z.string().title('Author Email').describe('Email of the comment author'),
postTitle: z.string().title('Post Title').describe('Title of the associated post'),
postId: z.string().title('Post ID').describe('ID of the associated post'),
created: z.string().title('Created').describe('Creation timestamp'),
internal: z.boolean().title('Internal').describe('Whether this is an internal comment'),
likeCount: z.number().title('Like Count').describe('Number of likes'),
})
)
.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().title('ID').describe('User ID'),
name: z.string().title('Name').describe('User display name'),
email: z.string().title('Email').describe('User email address'),
avatarURL: z.string().optional().title('Avatar URL').describe('User avatar URL'),
userId: z.string().title('User ID').describe('Your internal user ID'),
isAdmin: z.boolean().title('Is Admin').describe('Whether the user is an admin'),
created: z.string().title('Created').describe('User creation timestamp'),
})
.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().title('ID').describe('User ID'),
name: z.string().title('Name').describe('User display name'),
email: z.string().title('Email').describe('User email address'),
avatarURL: z.string().optional().title('Avatar URL').describe('User avatar URL'),
userId: z.string().title('User ID').describe('Your internal user ID'), // Can be empty string if user has no userId
isAdmin: z.boolean().title('Is Admin').describe('Whether the user is an admin'),
created: z.string().title('Created').describe('User creation timestamp'),
})
)
.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().title('ID').describe('Board ID'),
name: z.string().title('Name').describe('Board name'),
postCount: z.number().title('Post Count').describe('Number of posts in the board'),
url: z.string().title('URL').describe('Board URL'),
created: z.string().title('Created').describe('Board creation timestamp'),
})
)
.title('Boards')
.describe('Array of boards'),
nextToken: z.number().optional().title('Next Token').describe('Token for next page if more boards available'),
}),
},
},
},
attributes: {
category: 'Project Management',
guideSlug: 'canny',
repo: 'botpress',
},
})
+20
View File
@@ -0,0 +1,20 @@
{
"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",
"test": "vitest --run"
},
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"axios": "^1.6.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
}
}
+49
View File
@@ -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)
}
}
+133
View File
@@ -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 {}
}
+207
View File
@@ -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 {}
}
+111
View File
@@ -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)
}
}
+58
View File
@@ -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()
},
},
}
@@ -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<WebhookHandler>[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<WebhookHandler>[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)
}
}
+53
View File
@@ -0,0 +1,53 @@
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,
})
// Note: We use a default Botpress user for comments/posts when no authorId is provided
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,
})
+291
View File
@@ -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<string, number>
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<string, any>
}
export type UpdatePostRequest = {
postId: string
title?: string
details?: string
eta?: string
etaPublic?: boolean
imageURLs?: string[]
customFields?: Record<string, any>
}
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<string, any>
}
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<Post> {
const response = await this._client.post('/posts/retrieve', { id, boardId })
return response.data
}
public async listPosts(request: ListPostsRequest = {}): Promise<ListPostsResponse> {
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<Comment> {
const response = await this._client.post('/comments/retrieve', { id })
return response.data
}
public async listComments(request: ListCommentsRequest = {}): Promise<ListCommentsResponse> {
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<User> {
const response = await this._client.post('/users/create_or_update', request)
return response.data
}
public async listUsers(request: ListUsersRequest = {}): Promise<ListUsersResponse> {
const response = await this._client.post('https://canny.io/api/v2/users/list', request)
return response.data
}
public async listBoards(request: ListBoardsRequest = {}): Promise<ListBoardsResponse> {
const response = await this._client.post('/boards/list', request)
return response.data
}
}
+32
View File
@@ -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}`)
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config