chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { nameCompare } from '../string-utils'
|
||||
import { printActionTriggeredMsg, getTools } from './helpers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getAllBoards: bp.Integration['actions']['getAllBoards'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const {} = props.input
|
||||
const boards = await trelloClient.getAllBoards()
|
||||
return { boards }
|
||||
}
|
||||
|
||||
export const getBoardById: bp.Integration['actions']['getBoardById'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { boardId } = props.input
|
||||
const board = await trelloClient.getBoardById({ boardId })
|
||||
|
||||
return { board }
|
||||
}
|
||||
|
||||
export const getBoardsByDisplayName: bp.Integration['actions']['getBoardsByDisplayName'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { boardName } = props.input
|
||||
const boards = await trelloClient.getAllBoards()
|
||||
const matchingBoards = boards.filter((b) => nameCompare(b.name, boardName))
|
||||
|
||||
return { boards: matchingBoards }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { z, RuntimeError } from '@botpress/sdk'
|
||||
import { nameCompare } from '../string-utils'
|
||||
import { CardPosition } from '../trello-api'
|
||||
import { printActionTriggeredMsg, getTools } from './helpers'
|
||||
import { moveCardVertically } from './move-card-helpers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getCardsInList: bp.Integration['actions']['getCardsInList'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { listId } = props.input
|
||||
const matchingCards = await trelloClient.getCardsInList({ listId })
|
||||
|
||||
return { cards: matchingCards }
|
||||
}
|
||||
|
||||
export const getCardById: bp.Integration['actions']['getCardById'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const card = await trelloClient.getCardById({ cardId: props.input.cardId })
|
||||
return { card }
|
||||
}
|
||||
|
||||
export const getCardsByDisplayName: bp.Integration['actions']['getCardsByDisplayName'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { listId, cardName } = props.input
|
||||
const cards = await trelloClient.getCardsInList({ listId })
|
||||
const matchingCards = cards.filter((c) => nameCompare(c.name, cardName))
|
||||
|
||||
return { cards: matchingCards }
|
||||
}
|
||||
|
||||
export const createCard: bp.Integration['actions']['createCard'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { listId, cardName, cardBody, memberIds, labelIds, dueDate, completionStatus } = props.input
|
||||
const newCard = await trelloClient.createCard({
|
||||
card: {
|
||||
name: cardName,
|
||||
description: cardBody ?? '',
|
||||
listId,
|
||||
memberIds,
|
||||
labelIds,
|
||||
dueDate,
|
||||
isCompleted: completionStatus === 'Complete',
|
||||
},
|
||||
})
|
||||
|
||||
return { message: `Card created successfully. Card ID: ${newCard.id}`, newCardId: newCard.id }
|
||||
}
|
||||
|
||||
const _verticalPositionSchema = z.union([z.literal('top'), z.literal('bottom'), z.coerce.number()]).optional()
|
||||
const _validateVerticalPosition = (verticalPosition: string | undefined): CardPosition | undefined => {
|
||||
const result = _verticalPositionSchema.safeParse(verticalPosition?.toLowerCase().trim())
|
||||
if (!result.success) {
|
||||
throw new RuntimeError(
|
||||
`Invalid verticalPosition value. It must be either "top", "bottom", or a float. -> ${result.error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export const updateCard: bp.Integration['actions']['updateCard'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const {
|
||||
cardId,
|
||||
listId,
|
||||
cardName,
|
||||
cardBody,
|
||||
lifecycleStatus,
|
||||
completionStatus,
|
||||
dueDate,
|
||||
labelIdsToAdd,
|
||||
labelIdsToRemove,
|
||||
memberIdsToAdd,
|
||||
memberIdsToRemove,
|
||||
verticalPosition,
|
||||
} = props.input
|
||||
|
||||
// When sending null for the due date, the Trello API ignores it. However, an empty string removes the due date.
|
||||
// A blank string from a Botpress bot should be treated as undefined (no change), while null is converted to an
|
||||
// empty string to should remove the due date.
|
||||
let formattedDueDate = dueDate?.trim() !== '' ? dueDate : undefined
|
||||
formattedDueDate = formattedDueDate !== null ? formattedDueDate : ''
|
||||
|
||||
const card = await trelloClient.getCardById({ cardId })
|
||||
await trelloClient.updateCard({
|
||||
partialCard: {
|
||||
id: cardId,
|
||||
listId,
|
||||
name: cardName,
|
||||
description: cardBody,
|
||||
isClosed: lifecycleStatus ? lifecycleStatus === 'Archived' : undefined,
|
||||
isCompleted: completionStatus ? completionStatus === 'Complete' : undefined,
|
||||
dueDate: formattedDueDate,
|
||||
labelIds: card.labelIds.concat(labelIdsToAdd ?? []).filter((labelId) => !labelIdsToRemove?.includes(labelId)),
|
||||
memberIds: card.memberIds
|
||||
.concat(memberIdsToAdd ?? [])
|
||||
.filter((memberId) => !memberIdsToRemove?.includes(memberId)),
|
||||
verticalPosition: _validateVerticalPosition(verticalPosition),
|
||||
},
|
||||
})
|
||||
|
||||
return { message: 'Card updated successfully.' }
|
||||
}
|
||||
|
||||
export const deleteCard: bp.Integration['actions']['deleteCard'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { cardId, hardDelete = false } = props.input
|
||||
|
||||
if (hardDelete) {
|
||||
await trelloClient.deleteCard(cardId)
|
||||
} else {
|
||||
await trelloClient.updateCard({
|
||||
partialCard: {
|
||||
id: cardId,
|
||||
isClosed: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export const moveCardToList: bp.Integration['actions']['moveCardToList'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { cardId, newListId, newVerticalPosition } = props.input
|
||||
const card = await trelloClient.getCardById({ cardId })
|
||||
const newList = await trelloClient.getListById({ listId: newListId })
|
||||
|
||||
await trelloClient.updateCard({
|
||||
partialCard: {
|
||||
id: card.id,
|
||||
listId: newList.id,
|
||||
verticalPosition: _validateVerticalPosition(newVerticalPosition),
|
||||
},
|
||||
})
|
||||
|
||||
return { message: 'Card successfully moved to the new list' }
|
||||
}
|
||||
|
||||
export const moveCardDown: bp.Integration['actions']['moveCardDown'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { cardId, moveDownByNSpaces } = props.input
|
||||
const numOfPositions = moveDownByNSpaces ?? 1
|
||||
await moveCardVertically({ trelloClient, cardId, numOfPositions })
|
||||
|
||||
return { message: 'Card successfully moved down' }
|
||||
}
|
||||
|
||||
export const moveCardUp: bp.Integration['actions']['moveCardUp'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { cardId, moveUpByNSpaces } = props.input
|
||||
const numOfPositions = -(moveUpByNSpaces ?? 1)
|
||||
await moveCardVertically({ trelloClient, cardId, numOfPositions })
|
||||
|
||||
return { message: 'Card successfully moved up' }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { printActionTriggeredMsg, getTools } from './helpers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const addCardComment: bp.Integration['actions']['addCardComment'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { cardId, commentBody } = props.input
|
||||
const newCommentId = await trelloClient.addCardComment({ cardId, commentBody })
|
||||
|
||||
return { message: 'Comment successfully added to the card', newCommentId }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TrelloClient } from '../trello-api/trello-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const printActionTriggeredMsg = ({ type, ctx, logger, input }: bp.AnyActionProps) => {
|
||||
logger.forBot().debug(`Running action "${type}" [bot id: ${ctx.botId}]`, { input })
|
||||
}
|
||||
|
||||
type ResolvedFactoryTools<T extends Record<string, (props: bp.CommonHandlerProps) => unknown>> = {
|
||||
readonly [K in keyof T]: ReturnType<T[K]>
|
||||
} & {} // <-- Empty object used for improving readability in IDE type previews
|
||||
|
||||
const _createToolFactory = <T extends Record<string, (props: bp.CommonHandlerProps) => unknown>>(toolBuilders: T) => {
|
||||
return (props: bp.CommonHandlerProps) => {
|
||||
// Of all the options I tested, Proxy had the best average
|
||||
// performance, especially as more "toolBuilders" are added.
|
||||
return new Proxy(toolBuilders, {
|
||||
get(target, tool: string) {
|
||||
const builder = target[tool]
|
||||
if (!builder) {
|
||||
// Sanity check, should never actually be thrown
|
||||
throw new Error(`No tool builder found for key: ${String(tool)}`)
|
||||
}
|
||||
return builder(props)
|
||||
},
|
||||
}) as ResolvedFactoryTools<T>
|
||||
}
|
||||
}
|
||||
|
||||
export const getTools = _createToolFactory({
|
||||
trelloClient({ ctx }: bp.CommonHandlerProps) {
|
||||
return new TrelloClient({ ctx })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getAllBoards, getBoardsByDisplayName, getBoardById } from './board-actions'
|
||||
import {
|
||||
getCardsInList,
|
||||
getCardsByDisplayName,
|
||||
getCardById,
|
||||
createCard,
|
||||
updateCard,
|
||||
moveCardToList,
|
||||
moveCardUp,
|
||||
moveCardDown,
|
||||
deleteCard,
|
||||
} from './card-actions'
|
||||
import { addCardComment } from './card-comment-actions'
|
||||
import { getListsInBoard, getListsByDisplayName, getListById } from './list-actions'
|
||||
import {
|
||||
getAllBoardMembers,
|
||||
getAllCardMembers,
|
||||
getMemberByIdOrUsername,
|
||||
getBoardMembersByDisplayName,
|
||||
} from './member-actions'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const actions = {
|
||||
// === Board Actions ===
|
||||
getBoardById,
|
||||
getBoardsByDisplayName,
|
||||
getAllBoards,
|
||||
// === List Actions ===
|
||||
getListById,
|
||||
getListsByDisplayName,
|
||||
getListsInBoard,
|
||||
// === Card Actions ===
|
||||
getCardById,
|
||||
getCardsByDisplayName,
|
||||
getCardsInList,
|
||||
createCard,
|
||||
updateCard,
|
||||
deleteCard,
|
||||
addCardComment,
|
||||
moveCardUp,
|
||||
moveCardDown,
|
||||
moveCardToList,
|
||||
// === Member Actions ===
|
||||
getMemberByIdOrUsername,
|
||||
getBoardMembersByDisplayName,
|
||||
getAllBoardMembers,
|
||||
getAllCardMembers,
|
||||
} as const satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,34 @@
|
||||
import { nameCompare } from '../string-utils'
|
||||
import { printActionTriggeredMsg, getTools } from './helpers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getListsInBoard: bp.Integration['actions']['getListsInBoard'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { boardId } = props.input
|
||||
const matchingLists = await trelloClient.getListsInBoard({ boardId })
|
||||
|
||||
return { lists: matchingLists }
|
||||
}
|
||||
|
||||
export const getListsByDisplayName: bp.Integration['actions']['getListsByDisplayName'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { boardId, listName } = props.input
|
||||
const lists = await trelloClient.getListsInBoard({ boardId })
|
||||
const matchingLists = lists.filter((l) => nameCompare(l.name, listName))
|
||||
|
||||
return { lists: matchingLists }
|
||||
}
|
||||
|
||||
export const getListById: bp.Integration['actions']['getListById'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { listId } = props.input
|
||||
const list = await trelloClient.getListById({ listId })
|
||||
|
||||
return { list }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { nameCompare } from '../string-utils'
|
||||
import { printActionTriggeredMsg, getTools } from './helpers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getAllBoardMembers: bp.Integration['actions']['getAllBoardMembers'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { boardId } = props.input
|
||||
const members = await trelloClient.getBoardMembers({ boardId })
|
||||
return { members }
|
||||
}
|
||||
|
||||
export const getAllCardMembers: bp.Integration['actions']['getAllCardMembers'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { cardId } = props.input
|
||||
return {
|
||||
members: await trelloClient.getCardMembers({ cardId }),
|
||||
}
|
||||
}
|
||||
|
||||
export const getBoardMembersByDisplayName: bp.Integration['actions']['getBoardMembersByDisplayName'] = async (
|
||||
props
|
||||
) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { boardId, displayName } = props.input
|
||||
const members = await trelloClient.getBoardMembers({ boardId })
|
||||
const matchingMembers = members.filter((m) => nameCompare(m.fullName, displayName))
|
||||
|
||||
return { members: matchingMembers }
|
||||
}
|
||||
|
||||
export const getMemberByIdOrUsername: bp.Integration['actions']['getMemberByIdOrUsername'] = async (props) => {
|
||||
printActionTriggeredMsg(props)
|
||||
const { trelloClient } = getTools(props)
|
||||
|
||||
const { memberIdOrUsername } = props.input
|
||||
const member = await trelloClient.getMemberByIdOrUsername({ memberId: memberIdOrUsername })
|
||||
|
||||
return { member }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { Card } from 'definitions/schemas'
|
||||
import { TrelloClient, CardPosition } from '../trello-api'
|
||||
|
||||
export const moveCardVertically = async ({
|
||||
trelloClient,
|
||||
cardId,
|
||||
numOfPositions,
|
||||
}: {
|
||||
trelloClient: TrelloClient
|
||||
cardId: string
|
||||
numOfPositions: number
|
||||
}): Promise<void> => {
|
||||
if (numOfPositions === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const card = await trelloClient.getCardById({ cardId })
|
||||
const cardsInList = await trelloClient.getCardsInList({ listId: card.listId })
|
||||
cardsInList.sort((a, b) => a.verticalPosition - b.verticalPosition)
|
||||
|
||||
const newPosition = _evaluateNewPosition(cardsInList, cardId, numOfPositions)
|
||||
|
||||
await trelloClient.updateCard({
|
||||
partialCard: {
|
||||
id: cardId,
|
||||
verticalPosition: newPosition,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const _evaluateNewPosition = (cardsInList: Card[], cardIdToMove: string, numOfPositions: number): CardPosition => {
|
||||
const cardIndex = cardsInList.findIndex((c) => c.id === cardIdToMove)
|
||||
if (cardIndex === -1) {
|
||||
throw new sdk.RuntimeError(`Card with id ${cardIdToMove} not found in the target list of cards`)
|
||||
}
|
||||
|
||||
const newIndex = cardIndex + numOfPositions
|
||||
|
||||
if (newIndex <= 0) {
|
||||
return 'top'
|
||||
}
|
||||
|
||||
if (newIndex >= cardsInList.length - 1) {
|
||||
return 'bottom'
|
||||
}
|
||||
|
||||
const sibling = cardsInList[newIndex]
|
||||
const otherSibling = cardsInList[newIndex + Math.sign(numOfPositions)]
|
||||
|
||||
if (!sibling || !otherSibling) {
|
||||
// Sanity check, should never actually be called
|
||||
throw new sdk.RuntimeError('Card must have a sibling card on each side to determine new position')
|
||||
}
|
||||
|
||||
// This is supposed to be a float value. For reference, check the "pos" property in the "Update a Card" request
|
||||
// parameters: https://developer.atlassian.com/cloud/trello/rest/api-group-cards/#api-cards-id-put-request
|
||||
return (sibling.verticalPosition + otherSibling.verticalPosition) / 2
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createChannelWrapper } from '@botpress/common'
|
||||
import { TrelloClient } from 'src/trello-api/trello-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const wrapChannel = createChannelWrapper<bp.IntegrationProps>()({
|
||||
toolFactories: {
|
||||
trelloClient: ({ ctx }) => new TrelloClient({ ctx }),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CardCommentPublisher } from './publishers/card-comments'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const channels = {
|
||||
cardComments: {
|
||||
messages: {
|
||||
text: CardCommentPublisher.publishTextMessage,
|
||||
},
|
||||
},
|
||||
} as const satisfies bp.IntegrationProps['channels']
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { wrapChannel } from '../channel-wrapper'
|
||||
|
||||
export namespace CardCommentPublisher {
|
||||
export const publishTextMessage = wrapChannel(
|
||||
{ channelName: 'cardComments', messageType: 'text' },
|
||||
async ({ trelloClient, conversation, ack, payload, client }) => {
|
||||
if (!conversation.tags.cardId) {
|
||||
throw new sdk.RuntimeError('Card id must be set')
|
||||
}
|
||||
|
||||
const commentId = await trelloClient.addCardComment({
|
||||
cardId: conversation.tags.cardId,
|
||||
commentBody: payload.text,
|
||||
})
|
||||
|
||||
await client.updateConversation({
|
||||
id: conversation.id,
|
||||
tags: {
|
||||
lastCommentId: commentId,
|
||||
},
|
||||
})
|
||||
|
||||
await ack({ tags: { commentId } })
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
|
||||
import { actions } from './actions'
|
||||
import { channels } from './channels/publisher-dispatcher'
|
||||
import { register, unregister } from './setup'
|
||||
import { handler } from './webhook-events'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const integrationConfig: bp.IntegrationProps = {
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
}
|
||||
|
||||
export default posthogHelper.wrapIntegration(
|
||||
{
|
||||
integrationName: INTEGRATION_NAME,
|
||||
key: bp.secrets.POSTHOG_KEY,
|
||||
integrationVersion: INTEGRATION_VERSION,
|
||||
},
|
||||
integrationConfig
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { TrelloClient } from './trello-api/trello-client'
|
||||
import {
|
||||
cleanupStaleWebhooks,
|
||||
registerTrelloWebhookIfNotExists,
|
||||
unregisterTrelloWebhooks,
|
||||
} from './webhook-lifecycle-utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const register: bp.Integration['unregister'] = async ({ webhookUrl, ...props }) => {
|
||||
const trelloClient = new TrelloClient({ ctx: props.ctx })
|
||||
await cleanupStaleWebhooks(props, webhookUrl, trelloClient)
|
||||
await registerTrelloWebhookIfNotExists(props, webhookUrl, trelloClient)
|
||||
}
|
||||
|
||||
export const unregister: bp.Integration['unregister'] = async ({ webhookUrl, ...props }) => {
|
||||
const trelloClient = new TrelloClient({ ctx: props.ctx })
|
||||
await unregisterTrelloWebhooks(props, webhookUrl, trelloClient)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
const _canonicalize = (identifier: string) => identifier.trim().toUpperCase().normalize()
|
||||
|
||||
export const nameCompare = (name1: string, name2: string) => _canonicalize(name1) === _canonicalize(name2)
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TrelloClient } from './trello-client'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Parameters } from 'trello.js'
|
||||
import { UpdateCardPayload, CreateCardPayload } from '../types'
|
||||
|
||||
export namespace RequestMapping {
|
||||
export const mapUpdateCard = (card: UpdateCardPayload): Parameters.UpdateCard =>
|
||||
_keepOnlySetProperties({
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
desc: card.description,
|
||||
idList: card.listId,
|
||||
pos: card.verticalPosition,
|
||||
closed: card.isClosed,
|
||||
dueComplete: card.isCompleted,
|
||||
due: card.dueDate,
|
||||
idLabels: card.labelIds,
|
||||
idMembers: card.memberIds,
|
||||
})
|
||||
|
||||
export const mapCreateCard = (card: CreateCardPayload): Parameters.CreateCard =>
|
||||
_keepOnlySetProperties({
|
||||
name: card.name,
|
||||
desc: card.description,
|
||||
idList: card.listId,
|
||||
due: card.dueDate,
|
||||
idLabels: card.labelIds,
|
||||
idMembers: card.memberIds,
|
||||
pos: card.verticalPosition,
|
||||
dueComplete: card.isCompleted,
|
||||
})
|
||||
}
|
||||
|
||||
export const _keepOnlySetProperties = <T extends Record<string, any>>(
|
||||
obj: T
|
||||
): {
|
||||
[K in keyof T as undefined extends T[K] ? (T[K] extends undefined ? never : K) : K]: T[K]
|
||||
} => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as any
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { List, Member, Board, TrelloID, Card, Webhook } from 'definitions/schemas'
|
||||
import { Models, type Models as TrelloJsModels } from 'trello.js'
|
||||
|
||||
export namespace ResponseMapping {
|
||||
export const mapMember = (member: TrelloJsModels.Member): Member => ({
|
||||
id: member.id ?? '',
|
||||
fullName: member.fullName ?? '',
|
||||
username: member.username ?? '',
|
||||
})
|
||||
|
||||
export const mapBoard = (board: TrelloJsModels.Board): Board => ({
|
||||
id: board.id,
|
||||
name: board.name ?? '',
|
||||
})
|
||||
|
||||
export const mapList = (list: TrelloJsModels.List): List => ({
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
})
|
||||
|
||||
export const mapTrelloId = (id?: Models.TrelloID): TrelloID => id ?? ''
|
||||
|
||||
export const mapCard = (card: TrelloJsModels.Card): Card => ({
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
description: card.desc,
|
||||
listId: card.idList,
|
||||
verticalPosition: card.pos,
|
||||
isClosed: card.closed,
|
||||
isCompleted: card.dueComplete,
|
||||
dueDate: card.due ?? undefined,
|
||||
labelIds: card.idLabels as TrelloID[],
|
||||
memberIds: card.idMembers as Member['id'][],
|
||||
})
|
||||
|
||||
export const mapWebhook = (webhook: TrelloJsModels.Webhook): Webhook => ({
|
||||
id: mapTrelloId(webhook.id),
|
||||
modelId: webhook.idModel ?? '',
|
||||
callbackUrl: webhook.callbackURL ?? '',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import {
|
||||
webhookSchema,
|
||||
type Board,
|
||||
type Card,
|
||||
type List,
|
||||
type Member,
|
||||
type TrelloID,
|
||||
type Webhook,
|
||||
} from 'definitions/schemas'
|
||||
import { TrelloClient as TrelloJs, type Models as TrelloJsModels } from 'trello.js'
|
||||
import { RequestMapping } from './mapping/request-mapping'
|
||||
import { ResponseMapping } from './mapping/response-mapping'
|
||||
import { UpdateCardPayload, CreateCardPayload } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _useHandleCaughtError = (message: string) => {
|
||||
return (thrown: unknown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`${message}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class TrelloClient {
|
||||
private readonly _trelloJs: TrelloJs
|
||||
private readonly _token: string
|
||||
|
||||
public constructor({ ctx }: { ctx: bp.Context }) {
|
||||
this._token = ctx.configuration.trelloApiToken
|
||||
this._trelloJs = new TrelloJs({ key: ctx.configuration.trelloApiKey, token: ctx.configuration.trelloApiToken })
|
||||
}
|
||||
|
||||
public async getBoardMembers({ boardId }: { boardId: Board['id'] }): Promise<Member[]> {
|
||||
const members = await this._trelloJs.boards
|
||||
.getBoardMembers<TrelloJsModels.Member[]>({
|
||||
id: boardId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to retrieve board members'))
|
||||
|
||||
return members.map(ResponseMapping.mapMember)
|
||||
}
|
||||
|
||||
public async getBoardById({ boardId }: { boardId: Board['id'] }): Promise<Board> {
|
||||
const board = await this._trelloJs.boards
|
||||
.getBoard({
|
||||
id: boardId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to retrieve board by id'))
|
||||
|
||||
return ResponseMapping.mapBoard(board)
|
||||
}
|
||||
|
||||
public async getAllBoards(): Promise<Board[]> {
|
||||
const boards = await this._trelloJs.members
|
||||
.getMemberBoards({
|
||||
id: 'me',
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to retrieve all boards'))
|
||||
|
||||
return boards.map(ResponseMapping.mapBoard)
|
||||
}
|
||||
|
||||
public async getListsInBoard({ boardId }: { boardId: Board['id'] }): Promise<List[]> {
|
||||
const lists = await this._trelloJs.boards
|
||||
.getBoardLists({
|
||||
id: boardId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to retrieve lists in board'))
|
||||
|
||||
return lists.map(ResponseMapping.mapList)
|
||||
}
|
||||
|
||||
public async addCardComment({ cardId, commentBody }: { cardId: Card['id']; commentBody: string }): Promise<TrelloID> {
|
||||
const comment = await this._trelloJs.cards
|
||||
.addCardComment({
|
||||
id: cardId,
|
||||
text: commentBody,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to add comment to card'))
|
||||
|
||||
return ResponseMapping.mapTrelloId(comment.id)
|
||||
}
|
||||
|
||||
public async getCardById({ cardId }: { cardId: Card['id'] }): Promise<Card> {
|
||||
const card = await this._trelloJs.cards
|
||||
.getCard({
|
||||
id: cardId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to get card by id'))
|
||||
|
||||
return ResponseMapping.mapCard(card)
|
||||
}
|
||||
|
||||
public async createCard({ card }: { card: CreateCardPayload }): Promise<Card> {
|
||||
const newCard = await this._trelloJs.cards
|
||||
.createCard(RequestMapping.mapCreateCard(card))
|
||||
.catch(_useHandleCaughtError('Failed to create card'))
|
||||
|
||||
return ResponseMapping.mapCard(newCard)
|
||||
}
|
||||
|
||||
public async updateCard({ partialCard }: { partialCard: UpdateCardPayload }): Promise<Card> {
|
||||
const updatedCard = await this._trelloJs.cards
|
||||
.updateCard(RequestMapping.mapUpdateCard(partialCard))
|
||||
.catch(_useHandleCaughtError('Failed to update card'))
|
||||
|
||||
return ResponseMapping.mapCard(updatedCard)
|
||||
}
|
||||
|
||||
/** Hard deletes a Trello card.
|
||||
*
|
||||
* @remark For soft deletion use "updateCard" with "isClosed" as true */
|
||||
public async deleteCard(cardId: Card['id']): Promise<void> {
|
||||
await this._trelloJs.cards
|
||||
.deleteCard({
|
||||
id: cardId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to delete card'))
|
||||
}
|
||||
|
||||
public async getListById({ listId }: { listId: List['id'] }): Promise<List> {
|
||||
const list = await this._trelloJs.lists
|
||||
.getList<TrelloJsModels.List>({
|
||||
id: listId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to get list by id'))
|
||||
|
||||
return ResponseMapping.mapList(list)
|
||||
}
|
||||
|
||||
public async getCardsInList({ listId }: { listId: List['id'] }): Promise<Card[]> {
|
||||
const cards = await this._trelloJs.lists
|
||||
.getListCards({
|
||||
id: listId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to get cards in list'))
|
||||
|
||||
return cards.map(ResponseMapping.mapCard)
|
||||
}
|
||||
|
||||
public async getMemberByIdOrUsername({ memberId }: { memberId: Member['id'] | Member['username'] }): Promise<Member> {
|
||||
const member = await this._trelloJs.members
|
||||
.getMember({
|
||||
id: memberId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to get member by id or username'))
|
||||
|
||||
return ResponseMapping.mapMember(member)
|
||||
}
|
||||
|
||||
public async listWebhooks(): Promise<Webhook[]> {
|
||||
const rawWebhooks = await this._trelloJs.tokens
|
||||
.getTokenWebhooks<TrelloJsModels.Webhook[]>({
|
||||
token: this._token,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to list webhooks'))
|
||||
|
||||
const mappedWebhooks = rawWebhooks.map(ResponseMapping.mapWebhook)
|
||||
const result = webhookSchema.array().safeParse(mappedWebhooks)
|
||||
|
||||
if (!result.success) {
|
||||
throw new RuntimeError('Unexpected webhook data format received from Trello')
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async createWebhook({
|
||||
description,
|
||||
url,
|
||||
modelId,
|
||||
}: {
|
||||
description: string
|
||||
url: string
|
||||
modelId: string
|
||||
}): Promise<Webhook> {
|
||||
const webhook = await this._trelloJs.webhooks
|
||||
.createWebhook({
|
||||
description,
|
||||
callbackURL: url,
|
||||
idModel: modelId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to create webhook'))
|
||||
|
||||
return ResponseMapping.mapWebhook(webhook)
|
||||
}
|
||||
|
||||
public async deleteWebhook({ id }: { id: string }): Promise<void> {
|
||||
await this._trelloJs.webhooks
|
||||
.deleteWebhook({
|
||||
id,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to delete webhook'))
|
||||
}
|
||||
|
||||
public async getCardMembers({ cardId }: { cardId: Card['id'] }): Promise<Member[]> {
|
||||
const members = await this._trelloJs.cards
|
||||
.getCardMembers<TrelloJsModels.Member[]>({
|
||||
id: cardId,
|
||||
})
|
||||
.catch(_useHandleCaughtError('Failed to get card members'))
|
||||
|
||||
return members.map(ResponseMapping.mapMember)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Card } from 'definitions/schemas'
|
||||
import { Merge } from 'src/types'
|
||||
|
||||
export type CardPosition = number | 'top' | 'bottom'
|
||||
|
||||
export type CreateCardPayload = Pick<Card, 'name' | 'description' | 'listId'> & Omit<Partial<Card>, 'id' | 'isClosed'>
|
||||
export type UpdateCardPayload = Merge<
|
||||
Pick<Card, 'id'> & Partial<Card>,
|
||||
{
|
||||
verticalPosition?: CardPosition
|
||||
}
|
||||
>
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Merge<A, B> = Omit<A, keyof B> & B
|
||||
|
||||
export type Result<T, E extends Error = Error> = { success: true; data: T } | { success: false; error: E }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Result } from './types'
|
||||
|
||||
export const safeParseJson = (json: string): Result<object> => {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
data: JSON.parse(json),
|
||||
} as const
|
||||
} catch (thrown: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: thrown instanceof Error ? thrown : new Error(String(thrown)),
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
export const safeParseRequestBody = (body: string | undefined): Result<object> => {
|
||||
if (!body?.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error('Request body is empty'),
|
||||
}
|
||||
}
|
||||
|
||||
return safeParseJson(body)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CommentAddedWebhook } from '../schemas/card-comment-webhook-schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type Conversation = Awaited<ReturnType<bp.Client['getOrCreateConversation']>>['conversation']
|
||||
type User = Awaited<ReturnType<bp.Client['getOrCreateUser']>>['user']
|
||||
|
||||
export const processInboundCommentChannelMessage = async (
|
||||
client: bp.HandlerProps['client'],
|
||||
webhookEvent: CommentAddedWebhook
|
||||
): Promise<void> => {
|
||||
const conversation = await _getOrCreateConversation(client, webhookEvent.data)
|
||||
const user = await _getOrCreateUser(client, webhookEvent.memberCreator)
|
||||
|
||||
const comment = _extractCommentData(webhookEvent)
|
||||
if (_checkIfMessageWasSentByOurselvesAndShouldBeIgnored(conversation, comment)) {
|
||||
return
|
||||
}
|
||||
|
||||
await _createMessage(client, conversation, user, comment)
|
||||
}
|
||||
|
||||
const _extractCommentData = (event: CommentAddedWebhook) =>
|
||||
({
|
||||
id: event.id,
|
||||
text: event.data.text,
|
||||
}) as const
|
||||
type CardComment = ReturnType<typeof _extractCommentData>
|
||||
|
||||
const _getOrCreateConversation = async (client: bp.HandlerProps['client'], eventData: CommentAddedWebhook['data']) => {
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'cardComments',
|
||||
tags: {
|
||||
listId: eventData.list.id,
|
||||
listName: eventData.list.name,
|
||||
cardId: eventData.card.id,
|
||||
cardName: eventData.card.name,
|
||||
},
|
||||
})
|
||||
|
||||
return conversation
|
||||
}
|
||||
|
||||
const _getOrCreateUser = async (
|
||||
client: bp.HandlerProps['client'],
|
||||
memberCreator: CommentAddedWebhook['memberCreator']
|
||||
) => {
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: {
|
||||
userId: memberCreator.id,
|
||||
},
|
||||
name: memberCreator.fullName,
|
||||
pictureUrl: `${memberCreator.avatarUrl}/50.png`,
|
||||
})
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
const _checkIfMessageWasSentByOurselvesAndShouldBeIgnored = (conversation: Conversation, comment: CardComment) =>
|
||||
conversation.tags.lastCommentId === comment.id
|
||||
|
||||
const _createMessage = async (
|
||||
client: bp.HandlerProps['client'],
|
||||
conversation: Conversation,
|
||||
user: User,
|
||||
comment: CardComment
|
||||
) => {
|
||||
await client.createMessage({
|
||||
conversationId: conversation.id,
|
||||
userId: user.id,
|
||||
type: 'text',
|
||||
payload: { text: comment.text },
|
||||
tags: { commentId: comment.id },
|
||||
})
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { CardAttachmentAddedWebhook, CardAttachmentRemovedWebhook } from '../schemas/card-attachment-webhook-schemas'
|
||||
import { extractCommonEventData, extractIdAndName } from './helpers'
|
||||
import { Expect, IsWebhookHandler } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleAttachmentAddedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.ATTACHMENT_ADDED_TO_CARD,
|
||||
webhookEvent: CardAttachmentAddedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
list: extractIdAndName(webhookEvent.data.list),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
attachment: webhookEvent.data.attachment,
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleAttachmentAddedEventTest = Expect<IsWebhookHandler<typeof handleAttachmentAddedEvent>>
|
||||
|
||||
export const handleAttachmentRemovedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.ATTACHMENT_REMOVED_FROM_CARD,
|
||||
webhookEvent: CardAttachmentRemovedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
attachment: webhookEvent.data.attachment,
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleAttachmentRemovedEventTest = Expect<IsWebhookHandler<typeof handleAttachmentRemovedEvent>>
|
||||
@@ -0,0 +1,78 @@
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { processInboundCommentChannelMessage } from '../channel-handlers/comment-channel-handler'
|
||||
import {
|
||||
CommentAddedWebhook,
|
||||
CommentDeletedWebhook,
|
||||
CommentUpdatedWebhook,
|
||||
} from '../schemas/card-comment-webhook-schemas'
|
||||
import { extractCommonEventData, extractIdAndName } from './helpers'
|
||||
import { Expect, IsWebhookHandler } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleCommentAddedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CARD_COMMENT_CREATED,
|
||||
webhookEvent: CommentAddedWebhook
|
||||
) => {
|
||||
const result = await Promise.allSettled([
|
||||
processInboundCommentChannelMessage(props.client, webhookEvent),
|
||||
props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
list: extractIdAndName(webhookEvent.data.list),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
comment: {
|
||||
id: webhookEvent.id,
|
||||
text: webhookEvent.data.text,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return result[1].status === 'fulfilled' ? result[1].value : null
|
||||
}
|
||||
type _HandleCommentAddedEventTest = Expect<IsWebhookHandler<typeof handleCommentAddedEvent>>
|
||||
|
||||
export const handleCommentUpdatedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CARD_COMMENT_UPDATED,
|
||||
webhookEvent: CommentUpdatedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
comment: {
|
||||
id: webhookEvent.data.action.id,
|
||||
text: webhookEvent.data.action.text,
|
||||
},
|
||||
old: {
|
||||
text: webhookEvent.data.old.text,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleCommentUpdatedEventTest = Expect<IsWebhookHandler<typeof handleCommentUpdatedEvent>>
|
||||
|
||||
export const handleCommentDeletedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CARD_COMMENT_DELETED,
|
||||
webhookEvent: CommentDeletedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
comment: {
|
||||
id: webhookEvent.data.action.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleCommentDeletedEventTest = Expect<IsWebhookHandler<typeof handleCommentDeletedEvent>>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import {
|
||||
CardCreatedWebhook,
|
||||
CardDeletedWebhook,
|
||||
CardUpdatedWebhook,
|
||||
CardVotesUpdatedWebhook,
|
||||
} from '../schemas/card-webhook-schemas'
|
||||
import { extractCommonEventData, extractIdAndName, extractIdAndNameIfExists } from './helpers'
|
||||
import { Expect, IsWebhookHandler } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleCardCreatedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CARD_CREATED,
|
||||
webhookEvent: CardCreatedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
list: extractIdAndName(webhookEvent.data.list),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleCardCreatedEventTest = Expect<IsWebhookHandler<typeof handleCardCreatedEvent>>
|
||||
|
||||
export const handleCardUpdatedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CARD_UPDATED,
|
||||
webhookEvent: CardUpdatedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: webhookEvent.data.card,
|
||||
old: webhookEvent.data.old,
|
||||
list: extractIdAndNameIfExists(webhookEvent.data.list),
|
||||
listBefore: extractIdAndNameIfExists(webhookEvent.data.listBefore),
|
||||
listAfter: extractIdAndNameIfExists(webhookEvent.data.listAfter),
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleCardUpdatedEventTest = Expect<IsWebhookHandler<typeof handleCardUpdatedEvent>>
|
||||
|
||||
export const handleCardDeletedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CARD_DELETED,
|
||||
webhookEvent: CardDeletedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
list: extractIdAndName(webhookEvent.data.list),
|
||||
card: {
|
||||
id: webhookEvent.data.card.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleCardDeletedEventTest = Expect<IsWebhookHandler<typeof handleCardDeletedEvent>>
|
||||
|
||||
export const handleCardVotesUpdatedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CARD_VOTES_UPDATED,
|
||||
webhookEvent: CardVotesUpdatedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
voted: webhookEvent.data.voted,
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleCardVotesUpdatedEventTest = Expect<IsWebhookHandler<typeof handleCardVotesUpdatedEvent>>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { CardLabelAddedWebhook, CardLabelRemovedWebhook } from '../schemas/card-label-webhook-schemas'
|
||||
import { extractCommonEventData, extractIdAndName } from './helpers'
|
||||
import { Expect, IsWebhookHandler } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _handleLabelChangedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.LABEL_ADDED_TO_CARD | TrelloEventType.LABEL_REMOVED_FROM_CARD,
|
||||
webhookEvent: CardLabelAddedWebhook | CardLabelRemovedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
label: webhookEvent.data.label,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const handleLabelAddedToCardEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.LABEL_ADDED_TO_CARD,
|
||||
webhookEvent: CardLabelAddedWebhook
|
||||
) => {
|
||||
return await _handleLabelChangedEvent(props, eventType, webhookEvent)
|
||||
}
|
||||
type _HandleLabelAddedToCardEventTest = Expect<IsWebhookHandler<typeof handleLabelAddedToCardEvent>>
|
||||
|
||||
export const handleLabelRemovedFromCardEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.LABEL_REMOVED_FROM_CARD,
|
||||
webhookEvent: CardLabelRemovedWebhook
|
||||
) => {
|
||||
return await _handleLabelChangedEvent(props, eventType, webhookEvent)
|
||||
}
|
||||
type _HandleLabelRemovedFromCardEventTest = Expect<IsWebhookHandler<typeof handleLabelRemovedFromCardEvent>>
|
||||
@@ -0,0 +1,116 @@
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import {
|
||||
ChecklistAddedToCardWebhook,
|
||||
ChecklistItemCreatedWebhook,
|
||||
ChecklistItemDeletedWebhook,
|
||||
ChecklistItemStatusUpdatedWebhook,
|
||||
ChecklistItemUpdatedWebhook,
|
||||
} from '../schemas/checklist-webhook-schemas'
|
||||
import { extractCommonEventData, extractIdAndName } from './helpers'
|
||||
import { Expect, IsWebhookHandler } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _extractCommonChecklistItemPayload = (
|
||||
webhookEvent: ChecklistItemCreatedWebhook | ChecklistItemDeletedWebhook | ChecklistItemStatusUpdatedWebhook
|
||||
) => ({
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
checklist: webhookEvent.data.checklist,
|
||||
checklistItem: {
|
||||
id: webhookEvent.data.checkItem.id,
|
||||
name: webhookEvent.data.checkItem.name,
|
||||
isCompleted: webhookEvent.data.checkItem.state === 'complete',
|
||||
textData: webhookEvent.data.checkItem.textData,
|
||||
},
|
||||
})
|
||||
|
||||
export const handleChecklistAddedToCardEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CHECKLIST_ADDED_TO_CARD,
|
||||
webhookEvent: ChecklistAddedToCardWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
checklist: webhookEvent.data.checklist,
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleChecklistAddedToCardEventTest = Expect<IsWebhookHandler<typeof handleChecklistAddedToCardEvent>>
|
||||
|
||||
export const handleChecklistItemCreatedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CHECKLIST_ITEM_CREATED,
|
||||
webhookEvent: ChecklistItemCreatedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: _extractCommonChecklistItemPayload(webhookEvent),
|
||||
})
|
||||
}
|
||||
type _HandleChecklistItemCreatedEventTest = Expect<IsWebhookHandler<typeof handleChecklistItemCreatedEvent>>
|
||||
|
||||
const _mapOldChecklistItemData = (oldData: ChecklistItemUpdatedWebhook['data']['old']) => {
|
||||
const { name, state, textData, dueReminder, due } = oldData
|
||||
return {
|
||||
name,
|
||||
isCompleted: state !== undefined ? state === 'complete' : undefined,
|
||||
textData,
|
||||
dueDate: due,
|
||||
dueDateReminder: dueReminder,
|
||||
}
|
||||
}
|
||||
|
||||
export const handleChecklistItemUpdatedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CHECKLIST_ITEM_UPDATED,
|
||||
webhookEvent: ChecklistItemUpdatedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
checklist: webhookEvent.data.checklist,
|
||||
checklistItem: {
|
||||
id: webhookEvent.data.checkItem.id,
|
||||
name: webhookEvent.data.checkItem.name,
|
||||
isCompleted: webhookEvent.data.checkItem.state === 'complete',
|
||||
textData: webhookEvent.data.checkItem.textData,
|
||||
dueDate: webhookEvent.data.checkItem.due,
|
||||
dueDateReminder: webhookEvent.data.checkItem.dueReminder,
|
||||
},
|
||||
old: _mapOldChecklistItemData(webhookEvent.data.old),
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleChecklistItemUpdatedEventTest = Expect<IsWebhookHandler<typeof handleChecklistItemUpdatedEvent>>
|
||||
|
||||
export const handleChecklistItemDeletedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CHECKLIST_ITEM_DELETED,
|
||||
webhookEvent: ChecklistItemDeletedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: _extractCommonChecklistItemPayload(webhookEvent),
|
||||
})
|
||||
}
|
||||
type _HandleChecklistItemDeletedEventTest = Expect<IsWebhookHandler<typeof handleChecklistItemDeletedEvent>>
|
||||
|
||||
export const handleChecklistItemStatusUpdatedEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.CHECKLIST_ITEM_STATUS_UPDATED,
|
||||
webhookEvent: ChecklistItemStatusUpdatedWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: _extractCommonChecklistItemPayload(webhookEvent),
|
||||
})
|
||||
}
|
||||
type _HandleChecklistItemStatusUpdatedEventTest = Expect<IsWebhookHandler<typeof handleChecklistItemStatusUpdatedEvent>>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CommonEventData } from 'definitions/events'
|
||||
import { TrelloWebhook } from '../schemas/common'
|
||||
|
||||
export const extractIdAndName = (obj: { id: string; name: string }) => {
|
||||
return {
|
||||
id: obj.id,
|
||||
name: obj.name,
|
||||
}
|
||||
}
|
||||
|
||||
export const extractIdAndNameIfExists = (obj: { id: string; name: string } | undefined) => {
|
||||
return obj ? extractIdAndName(obj) : undefined
|
||||
}
|
||||
|
||||
const _extractActorData = (webhookEvent: TrelloWebhook): CommonEventData['actor'] => {
|
||||
if (webhookEvent.appCreator !== null) {
|
||||
return {
|
||||
id: webhookEvent.appCreator.id,
|
||||
type: 'app' as const,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
id: webhookEvent.memberCreator.id,
|
||||
type: 'member' as const,
|
||||
name: webhookEvent.memberCreator.fullName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const extractCommonEventData = (webhookEvent: TrelloWebhook): CommonEventData => {
|
||||
return {
|
||||
eventId: webhookEvent.id,
|
||||
actor: _extractActorData(webhookEvent),
|
||||
dateCreated: webhookEvent.date.toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { WebhookEventPayload } from '../schemas'
|
||||
import * as attachmentHandlers from './card-attachment-event-handlers'
|
||||
import * as commentHandlers from './card-comment-event-handlers'
|
||||
import * as cardHandlers from './card-event-handlers'
|
||||
import * as labelHandlers from './card-label-event-handlers'
|
||||
import * as checklistHandlers from './checklist-event-handlers'
|
||||
import * as memberHandlers from './member-event-handlers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const dispatchIntegrationEvent = async (props: bp.HandlerProps, eventPayload: WebhookEventPayload) => {
|
||||
const eventType = eventPayload.action.type
|
||||
switch (eventType) {
|
||||
case TrelloEventType.CARD_CREATED:
|
||||
return cardHandlers.handleCardCreatedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CARD_UPDATED:
|
||||
return cardHandlers.handleCardUpdatedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CARD_DELETED:
|
||||
return cardHandlers.handleCardDeletedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CARD_VOTES_UPDATED:
|
||||
return cardHandlers.handleCardVotesUpdatedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CARD_COMMENT_CREATED:
|
||||
return commentHandlers.handleCommentAddedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CARD_COMMENT_UPDATED:
|
||||
return commentHandlers.handleCommentUpdatedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CARD_COMMENT_DELETED:
|
||||
return commentHandlers.handleCommentDeletedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.LABEL_ADDED_TO_CARD:
|
||||
return labelHandlers.handleLabelAddedToCardEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.LABEL_REMOVED_FROM_CARD:
|
||||
return labelHandlers.handleLabelRemovedFromCardEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.ATTACHMENT_ADDED_TO_CARD:
|
||||
return attachmentHandlers.handleAttachmentAddedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.ATTACHMENT_REMOVED_FROM_CARD:
|
||||
return attachmentHandlers.handleAttachmentRemovedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CHECKLIST_ADDED_TO_CARD:
|
||||
return checklistHandlers.handleChecklistAddedToCardEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CHECKLIST_ITEM_CREATED:
|
||||
return checklistHandlers.handleChecklistItemCreatedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CHECKLIST_ITEM_UPDATED:
|
||||
return checklistHandlers.handleChecklistItemUpdatedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CHECKLIST_ITEM_DELETED:
|
||||
return checklistHandlers.handleChecklistItemDeletedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.CHECKLIST_ITEM_STATUS_UPDATED:
|
||||
return checklistHandlers.handleChecklistItemStatusUpdatedEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.MEMBER_ADDED_TO_CARD:
|
||||
return memberHandlers.handleMemberAddedToCardEvent(props, eventType, eventPayload.action)
|
||||
case TrelloEventType.MEMBER_REMOVED_FROM_CARD:
|
||||
return memberHandlers.handleMemberRemovedFromCardEvent(props, eventType, eventPayload.action)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { MemberAddedToCardWebhook, MemberRemovedFromCardWebhook } from '../schemas/member-webhook-schemas'
|
||||
import { extractCommonEventData, extractIdAndName } from './helpers'
|
||||
import { Expect, IsWebhookHandler } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleMemberAddedToCardEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.MEMBER_ADDED_TO_CARD,
|
||||
webhookEvent: MemberAddedToCardWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
member: extractIdAndName(webhookEvent.data.member),
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleMemberAddedToCardEventTest = Expect<IsWebhookHandler<typeof handleMemberAddedToCardEvent>>
|
||||
|
||||
export const handleMemberRemovedFromCardEvent = async (
|
||||
props: bp.HandlerProps,
|
||||
eventType: TrelloEventType.MEMBER_REMOVED_FROM_CARD,
|
||||
webhookEvent: MemberRemovedFromCardWebhook
|
||||
) => {
|
||||
return await props.client.createEvent({
|
||||
type: eventType,
|
||||
payload: {
|
||||
...extractCommonEventData(webhookEvent),
|
||||
board: extractIdAndName(webhookEvent.data.board),
|
||||
card: extractIdAndName(webhookEvent.data.card),
|
||||
member: {
|
||||
...extractIdAndName(webhookEvent.data.member),
|
||||
deactivated: webhookEvent.data.deactivated,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
type _HandleMemberRemovedFromCardEventTest = Expect<IsWebhookHandler<typeof handleMemberRemovedFromCardEvent>>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { TrelloWebhook } from '../schemas/common'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
/** Used to enforce the signature of a webhook event handler */
|
||||
type WebhookEventHandler = (
|
||||
props: bp.HandlerProps,
|
||||
eventType: keyof typeof bp.events,
|
||||
eventPayload: Required<TrelloWebhook>
|
||||
) => Promise<Awaited<ReturnType<bp.Client['createEvent']>> | null>
|
||||
|
||||
// Type testing utils
|
||||
export type IsWebhookHandler<Handler extends (...args: any) => any> =
|
||||
Parameters<Handler> extends Parameters<WebhookEventHandler>
|
||||
? ReturnType<Handler> extends ReturnType<WebhookEventHandler>
|
||||
? true
|
||||
: false
|
||||
: false
|
||||
export type Expect<_T extends true> = void
|
||||
@@ -0,0 +1,82 @@
|
||||
import crypto from 'crypto'
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { Result } from '../types'
|
||||
import { safeParseRequestBody } from '../utils'
|
||||
import { dispatchIntegrationEvent } from './event-handlers'
|
||||
import { fallbackEventPayloadSchema, WebhookEventPayload, webhookEventPayloadSchema } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler = async (props: bp.HandlerProps): Promise<void> => {
|
||||
if (_verifyWebhookSignature(props)) {
|
||||
props.logger.forBot().error('The provided webhook payload failed its signature validation')
|
||||
return
|
||||
}
|
||||
|
||||
const payloadResult = _parseWebhookPayload(props)
|
||||
if (!payloadResult.success) {
|
||||
const { error } = payloadResult
|
||||
props.logger.forBot().error(error.message, error)
|
||||
return
|
||||
}
|
||||
|
||||
await dispatchIntegrationEvent(props, payloadResult.data)
|
||||
}
|
||||
|
||||
const _isSupportedEventType = (type: string) => Object.values<string>(TrelloEventType).includes(type)
|
||||
|
||||
const _parseWebhookPayload = (props: bp.HandlerProps): Result<WebhookEventPayload> => {
|
||||
const result = safeParseRequestBody(props.req.body)
|
||||
if (!result.success) return result
|
||||
|
||||
const payloadResult = webhookEventPayloadSchema.safeParse(result.data)
|
||||
if (payloadResult.success) return payloadResult
|
||||
|
||||
// Checks for payloads that don't match supported events, or if a supported
|
||||
// event has a data structure that doesn't match the configured event schema
|
||||
const fallbackPayloadResult = fallbackEventPayloadSchema.safeParse(result.data)
|
||||
if (!fallbackPayloadResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error(`The webhook payload has an unexpected format -> ${fallbackPayloadResult.error.message}`),
|
||||
}
|
||||
}
|
||||
|
||||
const eventType = fallbackPayloadResult.data.action.type
|
||||
if (_isSupportedEventType(eventType)) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error(
|
||||
`The event data for the supported event type '${eventType}' has an unexpected format -> ${payloadResult.error.message}`
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: new Error(`Unsupported Trello event type: '${eventType}'`),
|
||||
}
|
||||
}
|
||||
|
||||
/** This only exists because for some reason `process.env.BP_WEBHOOK_URL` is not set */
|
||||
const _getWebhookUrl = (ctx: bp.Context) =>
|
||||
`${process.env.BP_API_URL}/${ctx.webhookId}`.replace(/\w+(?=\.botpress)/, 'webhook')
|
||||
|
||||
const _base64Digest = (secret: string, content: string) => {
|
||||
return crypto.createHmac('sha1', secret).update(content).digest('base64')
|
||||
}
|
||||
|
||||
const _verifyWebhookSignature = (props: bp.HandlerProps) => {
|
||||
const { req, ctx } = props
|
||||
const { trelloApiSecret } = ctx.configuration
|
||||
if (!trelloApiSecret) {
|
||||
// No secret configured, skip verification
|
||||
return true
|
||||
}
|
||||
|
||||
const callbackURL = _getWebhookUrl(ctx)
|
||||
|
||||
const content = (req.body ?? '') + callbackURL
|
||||
const doubleHash = _base64Digest(trelloApiSecret, content)
|
||||
const headerHash = req.headers['x-trello-webhook']
|
||||
return doubleHash === headerHash
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { handler } from './handler-dispatcher'
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { pickIdAndName } from 'definitions/events/common'
|
||||
import { boardSchema, cardSchema, listSchema, trelloIdSchema } from 'definitions/schemas'
|
||||
import { trelloWebhookSchema } from './common'
|
||||
|
||||
export const cardAttachmentAddedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.ATTACHMENT_ADDED_TO_CARD),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
list: pickIdAndName(listSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
attachment: z.object({
|
||||
id: trelloIdSchema,
|
||||
name: z.string(),
|
||||
url: z.string().url(),
|
||||
previewUrl: z.string().url().optional(),
|
||||
previewUrl2x: z.string().url().optional(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
export type CardAttachmentAddedWebhook = z.infer<typeof cardAttachmentAddedWebhookSchema>
|
||||
|
||||
export const cardAttachmentRemovedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.ATTACHMENT_REMOVED_FROM_CARD),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
attachment: z.object({
|
||||
id: trelloIdSchema,
|
||||
name: z.string(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
export type CardAttachmentRemovedWebhook = z.infer<typeof cardAttachmentRemovedWebhookSchema>
|
||||
@@ -0,0 +1,48 @@
|
||||
/** NOTE: The "brands" are only for documentation purposes, since Trello
|
||||
* is very inconsistent with the data structures for the comment events */
|
||||
import { z } from '@botpress/sdk'
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { pickIdAndName } from 'definitions/events/common'
|
||||
import { boardSchema, cardSchema, listSchema, trelloIdSchema } from 'definitions/schemas'
|
||||
import { trelloWebhookSchema } from './common'
|
||||
|
||||
export const commentAddedWebhookSchema = trelloWebhookSchema.extend({
|
||||
/** @remark This is only the comment ID for the comment added event */
|
||||
id: trelloIdSchema.brand('EventID').brand('CommentID'),
|
||||
type: z.literal(TrelloEventType.CARD_COMMENT_CREATED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
list: pickIdAndName(listSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
text: z.string().brand('CommentText'),
|
||||
}),
|
||||
})
|
||||
export type CommentAddedWebhook = z.infer<typeof commentAddedWebhookSchema>
|
||||
|
||||
export const commentUpdatedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CARD_COMMENT_UPDATED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
action: z.object({
|
||||
id: trelloIdSchema.brand('CommentID'),
|
||||
text: z.string().brand('NewCommentText'),
|
||||
}),
|
||||
old: z.object({
|
||||
text: z.string().brand('OldCommentText'),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
export type CommentUpdatedWebhook = z.infer<typeof commentUpdatedWebhookSchema>
|
||||
|
||||
export const commentDeletedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CARD_COMMENT_DELETED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
action: z.object({
|
||||
id: trelloIdSchema.brand('CommentID'),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
export type CommentDeletedWebhook = z.infer<typeof commentDeletedWebhookSchema>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { labelSchema } from 'definitions/events/card-label-events'
|
||||
import { pickIdAndName } from 'definitions/events/common'
|
||||
import { boardSchema, cardSchema } from 'definitions/schemas'
|
||||
import { trelloWebhookSchema } from './common'
|
||||
|
||||
export const cardLabelAddedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.LABEL_ADDED_TO_CARD),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
label: labelSchema,
|
||||
}),
|
||||
})
|
||||
export type CardLabelAddedWebhook = z.infer<typeof cardLabelAddedWebhookSchema>
|
||||
|
||||
export const cardLabelRemovedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.LABEL_REMOVED_FROM_CARD),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
label: labelSchema,
|
||||
}),
|
||||
})
|
||||
export type CardLabelRemovedWebhook = z.infer<typeof cardLabelRemovedWebhookSchema>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { dueReminderSchema, pickIdAndName, TrelloEventType } from 'definitions/events/common'
|
||||
import { boardSchema, cardSchema, listSchema, trelloIdSchema } from 'definitions/schemas'
|
||||
import { trelloWebhookSchema } from './common'
|
||||
|
||||
const _basicListSchema = pickIdAndName(listSchema)
|
||||
const _basicCardSchema = pickIdAndName(cardSchema)
|
||||
|
||||
export const cardCreatedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CARD_CREATED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
list: _basicListSchema,
|
||||
card: _basicCardSchema,
|
||||
}),
|
||||
})
|
||||
export type CardCreatedWebhook = z.infer<typeof cardCreatedWebhookSchema>
|
||||
|
||||
const _baseCardUpdateDataSchema = _basicCardSchema
|
||||
.extend({
|
||||
// These properties are only included if their values were modified
|
||||
desc: z.string(),
|
||||
idList: trelloIdSchema,
|
||||
idLabels: z.array(trelloIdSchema),
|
||||
pos: z.number(),
|
||||
start: z.string().datetime().nullable(),
|
||||
due: z.string().datetime().nullable(),
|
||||
dueReminder: dueReminderSchema.nullable(),
|
||||
dueComplete: z.boolean(),
|
||||
closed: z.boolean(),
|
||||
})
|
||||
.passthrough()
|
||||
.partial()
|
||||
|
||||
export const cardUpdatedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CARD_UPDATED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
// Seemed to only be excluded/optional when the card is moved between lists
|
||||
list: _basicListSchema.optional(),
|
||||
card: _baseCardUpdateDataSchema.required({ id: true, name: true }),
|
||||
old: _baseCardUpdateDataSchema.omit({ id: true }),
|
||||
// Only included if the card was moved between lists
|
||||
listBefore: _basicListSchema.optional(),
|
||||
// Only included if the card was moved between lists
|
||||
listAfter: _basicListSchema.optional(),
|
||||
}),
|
||||
})
|
||||
export type CardUpdatedWebhook = z.infer<typeof cardUpdatedWebhookSchema>
|
||||
|
||||
export const cardDeletedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CARD_DELETED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
list: _basicListSchema,
|
||||
card: _basicCardSchema.pick({ id: true }),
|
||||
}),
|
||||
})
|
||||
export type CardDeletedWebhook = z.infer<typeof cardDeletedWebhookSchema>
|
||||
|
||||
export const cardVotesUpdatedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CARD_VOTES_UPDATED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: _basicCardSchema,
|
||||
voted: z.boolean(),
|
||||
}),
|
||||
})
|
||||
export type CardVotesUpdatedWebhook = z.infer<typeof cardVotesUpdatedWebhookSchema>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { checklistSchema } from 'definitions/events/checklist-events'
|
||||
import { dueReminderSchema, pickIdAndName } from 'definitions/events/common'
|
||||
import { boardSchema, cardSchema, trelloIdSchema } from 'definitions/schemas'
|
||||
import { trelloWebhookSchema } from './common'
|
||||
|
||||
const _checklistItemCompletionStateSchema = z.union([z.literal('complete'), z.literal('incomplete')])
|
||||
|
||||
const _basicChecklistItemSchema = z.object({
|
||||
id: trelloIdSchema,
|
||||
name: z.string(),
|
||||
state: _checklistItemCompletionStateSchema,
|
||||
textData: z.object({
|
||||
emoji: z.object({}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const checklistAddedToCardWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CHECKLIST_ADDED_TO_CARD),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
checklist: checklistSchema,
|
||||
}),
|
||||
})
|
||||
export type ChecklistAddedToCardWebhook = z.infer<typeof checklistAddedToCardWebhookSchema>
|
||||
|
||||
export const checklistItemCreatedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CHECKLIST_ITEM_CREATED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
checklist: checklistSchema,
|
||||
checkItem: _basicChecklistItemSchema,
|
||||
}),
|
||||
})
|
||||
export type ChecklistItemCreatedWebhook = z.infer<typeof checklistItemCreatedWebhookSchema>
|
||||
|
||||
export const checklistItemUpdatedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CHECKLIST_ITEM_UPDATED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
checklist: checklistSchema,
|
||||
checkItem: _basicChecklistItemSchema.extend({
|
||||
dueReminder: dueReminderSchema.optional(),
|
||||
// Technically optional, if I include the "updateCheckItemDue" event type. Otherwise, it isn't included in "CHECKLIST_ITEM_UPDATED" event
|
||||
due: z.string().datetime().optional(),
|
||||
}),
|
||||
old: _basicChecklistItemSchema.omit({ id: true }).partial().extend({
|
||||
dueReminder: dueReminderSchema.optional(),
|
||||
// Technically optional, if I include the "updateCheckItemDue" event type. Otherwise, it isn't included in "CHECKLIST_ITEM_UPDATED" event
|
||||
due: z.string().datetime().optional(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
export type ChecklistItemUpdatedWebhook = z.infer<typeof checklistItemUpdatedWebhookSchema>
|
||||
|
||||
export const checklistItemDeletedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CHECKLIST_ITEM_DELETED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
checklist: checklistSchema,
|
||||
checkItem: _basicChecklistItemSchema,
|
||||
}),
|
||||
})
|
||||
export type ChecklistItemDeletedWebhook = z.infer<typeof checklistItemDeletedWebhookSchema>
|
||||
|
||||
export const checklistItemStatusUpdatedWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.CHECKLIST_ITEM_STATUS_UPDATED),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
checklist: checklistSchema,
|
||||
checkItem: _basicChecklistItemSchema,
|
||||
}),
|
||||
})
|
||||
export type ChecklistItemStatusUpdatedWebhook = z.infer<typeof checklistItemStatusUpdatedWebhookSchema>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { trelloIdSchema } from 'definitions/schemas'
|
||||
|
||||
export const trelloWebhookSchema = z.object({
|
||||
/** Action ID (aka Event ID) */
|
||||
id: trelloIdSchema,
|
||||
/** Event Triggered Date */
|
||||
date: z.coerce.date(),
|
||||
type: z.nativeEnum(TrelloEventType),
|
||||
data: z.any(),
|
||||
/** The Trello app that triggered the event. (e.g. Via http request using an API key & token)
|
||||
*
|
||||
* @remark This field is `null` when the event was not triggered by an app. */
|
||||
appCreator: z
|
||||
.object({
|
||||
/** Some internal Trello identifier for the app that triggered the event.
|
||||
*
|
||||
* @remark At the time of writing (2026-01-15), this field cannot be linked back to any
|
||||
* specific API key or token, as Trello does not expose an endpoint for that purpose.
|
||||
* Also, through testing each of the endpoints, I have not been able to find it within
|
||||
* other endpoint responses. */
|
||||
id: trelloIdSchema,
|
||||
})
|
||||
.nullable(),
|
||||
/** Member who initiated the action, or the member
|
||||
* who created the app that initiated the action
|
||||
* (if "appCreator" is not null).
|
||||
*
|
||||
* @remark This still appears to be present even if
|
||||
* the action was initiated via an API key & token. */
|
||||
memberCreator: z.object({
|
||||
id: trelloIdSchema,
|
||||
fullName: z.string(),
|
||||
username: z.string(),
|
||||
initials: z.string(),
|
||||
avatarHash: z.string(),
|
||||
avatarUrl: z.string(),
|
||||
}),
|
||||
})
|
||||
export type TrelloWebhook = z.infer<typeof trelloWebhookSchema>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { trelloIdSchema } from 'definitions/schemas'
|
||||
import { cardAttachmentAddedWebhookSchema, cardAttachmentRemovedWebhookSchema } from './card-attachment-webhook-schemas'
|
||||
import {
|
||||
commentAddedWebhookSchema,
|
||||
commentDeletedWebhookSchema,
|
||||
commentUpdatedWebhookSchema,
|
||||
} from './card-comment-webhook-schemas'
|
||||
import { cardLabelAddedWebhookSchema, cardLabelRemovedWebhookSchema } from './card-label-webhook-schemas'
|
||||
import {
|
||||
cardCreatedWebhookSchema,
|
||||
cardDeletedWebhookSchema,
|
||||
cardUpdatedWebhookSchema,
|
||||
cardVotesUpdatedWebhookSchema,
|
||||
} from './card-webhook-schemas'
|
||||
import {
|
||||
checklistAddedToCardWebhookSchema,
|
||||
checklistItemCreatedWebhookSchema,
|
||||
checklistItemDeletedWebhookSchema,
|
||||
checklistItemStatusUpdatedWebhookSchema,
|
||||
checklistItemUpdatedWebhookSchema,
|
||||
} from './checklist-webhook-schemas'
|
||||
import { trelloWebhookSchema } from './common'
|
||||
import { memberAddedToCardWebhookSchema, memberRemovedFromCardWebhookSchema } from './member-webhook-schemas'
|
||||
|
||||
const _webhookDetailsSchema = z.object({
|
||||
id: trelloIdSchema,
|
||||
idModel: trelloIdSchema,
|
||||
active: z.boolean(),
|
||||
consecutiveFailures: z.number().min(0),
|
||||
})
|
||||
|
||||
export const webhookEventPayloadSchema = z.object({
|
||||
action: z.union([
|
||||
// ---- Card Events ----
|
||||
cardCreatedWebhookSchema,
|
||||
cardUpdatedWebhookSchema,
|
||||
cardDeletedWebhookSchema,
|
||||
cardVotesUpdatedWebhookSchema,
|
||||
// ---- Card Comment Events ----
|
||||
commentAddedWebhookSchema,
|
||||
commentUpdatedWebhookSchema,
|
||||
commentDeletedWebhookSchema,
|
||||
// ---- Card Label Events ----
|
||||
cardLabelAddedWebhookSchema,
|
||||
cardLabelRemovedWebhookSchema,
|
||||
// ---- Card Attachment Events ----
|
||||
cardAttachmentAddedWebhookSchema,
|
||||
cardAttachmentRemovedWebhookSchema,
|
||||
// ---- Checklist Events ----
|
||||
checklistAddedToCardWebhookSchema,
|
||||
checklistItemCreatedWebhookSchema,
|
||||
checklistItemUpdatedWebhookSchema,
|
||||
checklistItemDeletedWebhookSchema,
|
||||
checklistItemStatusUpdatedWebhookSchema,
|
||||
// ---- Member Events ----
|
||||
memberAddedToCardWebhookSchema,
|
||||
memberRemovedFromCardWebhookSchema,
|
||||
]),
|
||||
webhook: _webhookDetailsSchema,
|
||||
})
|
||||
export type WebhookEventPayload = z.infer<typeof webhookEventPayloadSchema>
|
||||
|
||||
/** Fallback schema for unsupported event types */
|
||||
export const fallbackEventPayloadSchema = z.object({
|
||||
action: trelloWebhookSchema.extend({ type: z.string() }),
|
||||
webhook: _webhookDetailsSchema,
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { TrelloEventType } from 'definitions/events'
|
||||
import { pickIdAndName } from 'definitions/events/common'
|
||||
import { eventMemberSchema } from 'definitions/events/member-events'
|
||||
import { boardSchema, cardSchema } from 'definitions/schemas'
|
||||
import { trelloWebhookSchema } from './common'
|
||||
|
||||
export const memberAddedToCardWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.MEMBER_ADDED_TO_CARD),
|
||||
data: z.object({
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
member: eventMemberSchema,
|
||||
}),
|
||||
})
|
||||
export type MemberAddedToCardWebhook = z.infer<typeof memberAddedToCardWebhookSchema>
|
||||
|
||||
export const memberRemovedFromCardWebhookSchema = trelloWebhookSchema.extend({
|
||||
type: z.literal(TrelloEventType.MEMBER_REMOVED_FROM_CARD),
|
||||
data: z.object({
|
||||
deactivated: z.boolean(),
|
||||
board: pickIdAndName(boardSchema),
|
||||
card: pickIdAndName(cardSchema),
|
||||
member: eventMemberSchema,
|
||||
}),
|
||||
})
|
||||
export type MemberRemovedFromCardWebhook = z.infer<typeof memberRemovedFromCardWebhookSchema>
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Webhook } from 'definitions/schemas'
|
||||
import { INTEGRATION_NAME } from 'integration.definition'
|
||||
import { TrelloClient } from './trello-api/trello-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _registerWebhook = async (
|
||||
props: bp.CommonHandlerProps,
|
||||
webhookUrl: string,
|
||||
modelId: string,
|
||||
trelloClient = new TrelloClient({ ctx: props.ctx })
|
||||
): Promise<void> => {
|
||||
const { ctx, logger } = props
|
||||
logger.forBot().info('Registering Trello webhook...')
|
||||
|
||||
await trelloClient.createWebhook({
|
||||
description: INTEGRATION_NAME + ctx.integrationId,
|
||||
url: webhookUrl,
|
||||
modelId,
|
||||
})
|
||||
}
|
||||
|
||||
export const registerTrelloWebhookIfNotExists = async (
|
||||
props: bp.CommonHandlerProps,
|
||||
webhookUrl: string,
|
||||
trelloClient = new TrelloClient({ ctx: props.ctx })
|
||||
): Promise<void> => {
|
||||
const { ctx, logger } = props
|
||||
const { trelloBoardId } = ctx.configuration
|
||||
if (!trelloBoardId) {
|
||||
logger.forBot().warn('No Trello board id provided. Skipping webhook registration...')
|
||||
return
|
||||
}
|
||||
|
||||
const registeredWebhooks = await trelloClient.listWebhooks()
|
||||
const isWebhookRegistered = registeredWebhooks.some(
|
||||
(webhook) => webhook.callbackUrl === webhookUrl && webhook.modelId === trelloBoardId
|
||||
)
|
||||
if (isWebhookRegistered) {
|
||||
logger.forBot().debug('Webhook already registered. Skipping registration...')
|
||||
return
|
||||
}
|
||||
|
||||
await _registerWebhook(props, webhookUrl, trelloBoardId, trelloClient)
|
||||
}
|
||||
|
||||
const _deleteWebhooksInList = async (trelloClient: TrelloClient, webhooks: Webhook[]): Promise<void> => {
|
||||
for (const webhook of webhooks) {
|
||||
await trelloClient.deleteWebhook({ id: webhook.id })
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes webhooks for models that we no longer want to track */
|
||||
export const cleanupStaleWebhooks = async (
|
||||
props: bp.CommonHandlerProps,
|
||||
webhookUrl: string,
|
||||
trelloClient = new TrelloClient({ ctx: props.ctx })
|
||||
): Promise<void> => {
|
||||
const registeredWebhooks = await trelloClient.listWebhooks()
|
||||
|
||||
const { trelloBoardId } = props.ctx.configuration
|
||||
const staleWebhooks = registeredWebhooks.filter((webhook) => {
|
||||
return webhook.callbackUrl === webhookUrl && webhook.modelId !== trelloBoardId
|
||||
})
|
||||
|
||||
await _deleteWebhooksInList(trelloClient, staleWebhooks)
|
||||
|
||||
if (staleWebhooks.length > 0) {
|
||||
props.logger.forBot().info(`Cleaned up ${staleWebhooks.length} stale Trello webhook(s).`)
|
||||
}
|
||||
}
|
||||
|
||||
export const unregisterTrelloWebhooks = async (
|
||||
props: bp.CommonHandlerProps,
|
||||
webhookUrl: string,
|
||||
trelloClient = new TrelloClient({ ctx: props.ctx })
|
||||
) => {
|
||||
const registeredWebhooks = await trelloClient.listWebhooks()
|
||||
const webhooksToDelete = registeredWebhooks.filter((webhook) => {
|
||||
return webhook.callbackUrl === webhookUrl
|
||||
})
|
||||
|
||||
await _deleteWebhooksInList(trelloClient, webhooksToDelete)
|
||||
|
||||
if (webhooksToDelete.length > 0) {
|
||||
props.logger.forBot().info(`Deleted ${webhooksToDelete.length} Trello webhook(s).`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user