chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -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 })
},
})
+48
View File
@@ -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 } })
}
)
}
+24
View File
@@ -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
)
+18
View File
@@ -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)
}
+3
View File
@@ -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
}
>
+3
View File
@@ -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 }
+26
View File
@@ -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 },
})
}
@@ -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).`)
}
}