import sdk from '@botpress/sdk' import * as crypto from 'crypto' import { TodoistClient } from '../todoist-api' import { handleCommentAddedEvent, isCommentAddedEvent } from './handlers/comment-added' import { oauthCallbackHandler } from './handlers/oauth-callback' import { handlePriorityChangedEvent, isPriorityChangedEvent } from './handlers/priority-changed' import { handleTaskCompletedEvent, isTaskCompletedEvent } from './handlers/task-completed' import { handleTaskCreatedEvent, isTaskCreatedEvent } from './handlers/task-created' import { eventSchema, Event } from './schemas' import * as bp from '.botpress' export type WebhookEventHandler = ( props: bp.HandlerProps & { event: T; initiatorUserId: string } ) => Promise | void type WebhookEventHandlerEntry = Readonly<[(event: Event) => event is T, WebhookEventHandler]> const EVENT_HANDLERS: Readonly[]> = [ [isCommentAddedEvent, handleCommentAddedEvent], [isTaskCreatedEvent, handleTaskCreatedEvent], [isPriorityChangedEvent, handlePriorityChangedEvent], [isTaskCompletedEvent, handleTaskCompletedEvent], ] as const export const handler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) => { const { logger, req } = props if (props.req.path.startsWith('/oauth')) { logger.forBot().info('Handling Todoist OAuth callback') return await oauthCallbackHandler(props) } if (!req.body) { return {} } console.debug(req) if (props.ctx.configurationType !== 'apiToken') { await _ensureWebhookIsAuthenticated(props) } await _dispatchEvent(props) return {} } const _ensureWebhookIsAuthenticated = async ({ req }: bp.HandlerProps) => { // Calculating the checksum is computationally expensive, so we first check // whether a specific string (the "shared secret") is present in the query // parameters to short-circuit the validation process. if (!_isSharedSecretValid(req) || !_isChecksumValid(req)) { throw new sdk.RuntimeError('Webhook request is not properly authenticated') } } const _isSharedSecretValid = (req: sdk.Request) => { const searchParams = new URLSearchParams(req.query) const sharedSecret = searchParams.get('shared_secret') return sharedSecret === bp.secrets.WEBHOOK_SHARED_SECRET } const _isChecksumValid = (req: sdk.Request) => { const http_headers = new Headers(req.headers as Record) const todoist_checksum = http_headers.get('X-Todoist-Hmac-SHA256') const actual_checksum = _computeRequestChecksum(req) return todoist_checksum === actual_checksum } const _computeRequestChecksum = (req: sdk.Request) => crypto .createHmac('sha256', bp.secrets.CLIENT_SECRET) .update(req.body ?? '') .digest('base64') const _dispatchEvent = async (props: bp.HandlerProps) => { const event = _getEventData(props) if (await _isEventFromBot(props, event)) { return } await _handleEventWithMatchingHandler(props, event) } const _isEventFromBot = async (props: bp.HandlerProps, event: Event): Promise => { const { user: botUser } = await props.client.getUser({ id: props.ctx.botUserId }) return botUser.id === event.initiator.id } const _handleEventWithMatchingHandler = async (props: bp.HandlerProps, event: Event): Promise => { const matchingHandler = _findMatchingHandler(props, event) if (!matchingHandler) { console.warn('Unsupported todoist event', event) return } const initiator = await _getEventInitiator({ client: props.client, event }) await matchingHandler({ ...props, event, initiatorUserId: initiator.id }) } const _findMatchingHandler = (props: bp.HandlerProps, event: Event) => { for (const [eventGuard, eventHandler] of EVENT_HANDLERS) { if (eventGuard(event)) { props.logger.forBot().debug(`Event matched with ${eventGuard.name}: firing handler ${eventHandler.name}`) return eventHandler } } return null } const _getEventData = ({ req }: bp.HandlerProps) => eventSchema.parse(JSON.parse(req.body ?? '')) const _getEventInitiator = async ({ client, event }: { client: bp.Client; event: Event }) => { const { user } = await client.getOrCreateUser({ name: event.initiator.full_name, pictureUrl: event.initiator.image_id ? TodoistClient.getUserAvatarUrl({ imageId: event.initiator.image_id }) : undefined, tags: { id: event.initiator.id }, }) return user }