chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user