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,26 @@
import { createActionWrapper } from '@botpress/common'
import { TodoistClient, wrapAsyncFnWithTryCatch } from '../todoist-api'
import * as bp from '.botpress'
export const wrapAction: typeof _wrapAction = (meta, actionImpl) =>
_wrapAction(meta, (props) =>
// NOTE: the TodoistClient class already has error handling with error
// redaction, so this try-catch wrapper will only ever be called
// if there's an error in the action implementation itself
wrapAsyncFnWithTryCatch(() => {
props.logger
.forBot()
.debug(`Running action "${meta.actionName}" [bot id: ${props.ctx.botId}]`, { input: props.input })
return actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
}, `Action Error: ${meta.errorMessageWhenFailed}`)()
)
const _wrapAction = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
todoistClient: TodoistClient.create,
},
extraMetadata: {} as {
errorMessageWhenFailed: string
},
})
@@ -0,0 +1,6 @@
import { wrapAction } from '../action-wrapper'
export const changeTaskPriority = wrapAction(
{ actionName: 'changeTaskPriority', errorMessageWhenFailed: 'Failed to change task priority' },
async ({ todoistClient }, { taskId, priority }) => await todoistClient.updateTask({ task: { id: taskId, priority } })
)
@@ -0,0 +1,10 @@
import { wrapAction } from '../action-wrapper'
export const createNewTask = wrapAction(
{ actionName: 'createNewTask', errorMessageWhenFailed: 'Failed to create task' },
async ({ todoistClient }, task) => {
const newTask = await todoistClient.createNewTask({ task })
return { taskId: newTask.id }
}
)
@@ -0,0 +1,6 @@
import { wrapAction } from '../action-wrapper'
export const getAllProjects = wrapAction(
{ actionName: 'getAllProjects', errorMessageWhenFailed: 'Failed to retrieve projects' },
async ({ todoistClient }) => ({ projects: await todoistClient.getAllProjects() })
)
@@ -0,0 +1,6 @@
import { wrapAction } from '../action-wrapper'
export const getAllSections = wrapAction(
{ actionName: 'getAllSections', errorMessageWhenFailed: 'Failed to retrieve sections' },
async ({ todoistClient }, { projectId }) => ({ sections: await todoistClient.getAllSections({ projectId }) })
)
@@ -0,0 +1,8 @@
import { wrapAction } from '../action-wrapper'
export const getAllTasks = wrapAction(
{ actionName: 'getAllTasks', errorMessageWhenFailed: 'Failed to retrieve tasks' },
async ({ todoistClient }, { projectId, labelName, sectionId }) => ({
tasks: await todoistClient.getAllTasks({ projectId, labelName, sectionId }),
})
)
@@ -0,0 +1,10 @@
import { wrapAction } from '../action-wrapper'
export const getProjectId = wrapAction(
{ actionName: 'getProjectId', errorMessageWhenFailed: 'Failed to retrieve project id' },
async ({ todoistClient }, { name }) => {
const project = await todoistClient.getProjectByName({ name })
return { projectId: project?.id ?? null }
}
)
@@ -0,0 +1,10 @@
import { wrapAction } from '../action-wrapper'
export const getTaskId = wrapAction(
{ actionName: 'getTaskId', errorMessageWhenFailed: 'Failed to retrieve task id' },
async ({ todoistClient }, { name }) => {
const task = await todoistClient.getTaskByName({ name })
return { taskId: task?.id ?? null }
}
)
+19
View File
@@ -0,0 +1,19 @@
import { changeTaskPriority } from './implementations/change-task-priority'
import { createNewTask } from './implementations/create-new-task'
import { getAllProjects } from './implementations/get-all-projects'
import { getAllSections } from './implementations/get-all-sections'
import { getAllTasks } from './implementations/get-all-tasks'
import { getProjectId } from './implementations/get-project-id'
import { getTaskId } from './implementations/get-task-id'
import * as bp from '.botpress'
export const actions = {
changeTaskPriority,
getProjectId,
getTaskId,
createNewTask,
getAllProjects,
getAllSections,
getAllTasks,
} as const satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,9 @@
import { createChannelWrapper } from '@botpress/common'
import { TodoistClient } from 'src/todoist-api'
import * as bp from '.botpress'
export const wrapChannel = createChannelWrapper<bp.IntegrationProps>()({
toolFactories: {
todoistClient: TodoistClient.create,
},
})
@@ -0,0 +1,10 @@
import { TaskCommentPublisher } from './publishers/task-comment'
import * as bp from '.botpress'
export const channels = {
comments: {
messages: {
text: TaskCommentPublisher.publishTextMessage,
},
},
} as const satisfies bp.IntegrationProps['channels']
@@ -0,0 +1,21 @@
import * as sdk from '@botpress/sdk'
import { wrapChannel } from '../channel-wrapper'
export namespace TaskCommentPublisher {
export const publishTextMessage = wrapChannel(
{ channelName: 'comments', messageType: 'text' },
async ({ todoistClient, conversation, ack, payload, logger }) => {
if (!conversation.tags.id) {
throw new sdk.RuntimeError('Task id must be set')
}
const taskId = conversation.tags.id
const content = payload.text
logger.forBot().info(`Creating comment on task "${taskId}" with content: "${content}"`)
const newComment = await todoistClient.commentOnTask({ taskId, content })
await ack({ tags: { id: newComment.id } })
}
)
}
+16
View File
@@ -0,0 +1,16 @@
import { reporting } from '@botpress/sdk-addons'
import { actions } from './actions'
import { channels } from './channels'
import { register, unregister } from './setup'
import { handler } from './webhook-events'
import * as bp from '.botpress'
const integration = new bp.Integration({
register,
unregister,
actions,
channels,
handler,
})
export default reporting.wrapIntegration(integration)
+34
View File
@@ -0,0 +1,34 @@
import * as sdk from '@botpress/sdk'
import { TodoistClient } from './todoist-api'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async ({ logger, client, ctx }) => {
logger.forBot().info('Registering Todoist integration')
if (ctx.configurationType === null) {
try {
await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
} catch {
throw new sdk.RuntimeError('Please authenticate with Todoist before enabling the integration')
}
}
const todoistClient = await TodoistClient.create({ client, ctx })
const userIdentity = await todoistClient.getAuthenticatedUserIdentity()
await client.updateUser({
id: ctx.botUserId,
name: userIdentity.name,
pictureUrl: userIdentity.pictureUrl,
tags: { id: userIdentity.id },
})
}
export const unregister: bp.IntegrationProps['unregister'] = async ({ logger }) => {
logger.forBot().info('Unregistering Todoist integration')
}
export default {
register,
unregister,
}
@@ -0,0 +1,9 @@
import {
createAsyncFnWrapperWithErrorRedaction,
defaultErrorRedactor,
createErrorHandlingDecorator,
} from '@botpress/common'
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(defaultErrorRedactor)
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
@@ -0,0 +1,2 @@
export { TodoistClient } from './todoist-client'
export * from './error-handling'
@@ -0,0 +1,3 @@
export const reverseScale = (value: number, min: number, max: number) => max - _clamp(value, min, max) + min
const _clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
@@ -0,0 +1,2 @@
export * from './response-mapping'
export * from './request-mapping'
@@ -0,0 +1,38 @@
import { Task as TodoistTask, AddTaskArgs, UpdateTaskArgs } from '@doist/todoist-api-typescript'
import { CreateTaskRequest, Task, UpdateTaskRequest } from '../types'
import { reverseScale } from './common/math-utils'
export namespace RequestMapping {
const MAX_PRIORITY = 1
const MIN_PRIORITY = 4
export const mapCreateTask = (task: CreateTaskRequest): AddTaskArgs => ({
content: task.content,
description: task.description,
projectId: task.projectId,
sectionId: task.sectionId,
parentId: task.parentTaskId,
order: task.positionWithinParent,
labels: task.labels,
priority: _mapPriority(task.priority ?? MIN_PRIORITY),
dueString: task.dueDate,
assigneeId: task.assignee,
..._mapDuration(task.duration),
})
export const mapUpdateTask = (task: UpdateTaskRequest): UpdateTaskArgs => ({
content: task.content,
description: task.description,
labels: task.labels,
priority: _mapPriority(task.priority ?? MIN_PRIORITY),
dueString: task.dueDate,
assigneeId: task.assignee,
..._mapDuration(task.duration),
})
const _mapPriority = (priority: Task['priority']): TodoistTask['priority'] =>
reverseScale(priority, MIN_PRIORITY, MAX_PRIORITY)
const _mapDuration = (duration: Task['duration']) =>
duration ? { duration: duration.amount, durationUnit: duration.unit } : {}
}
@@ -0,0 +1,72 @@
import {
Task as TodoistTask,
Project as TodoistProject,
Comment as TodoistComment,
Section as TodoistSection,
} from '@doist/todoist-api-typescript'
import * as entities from 'definitions/entities'
import { Task, Project, Comment, PickRequired, Section } from '../types'
import { reverseScale } from './common/math-utils'
export namespace ResponseMapping {
const MAX_PRIORITY = 4
const MIN_PRIORITY = 1
export const mapTask = (todoistTask: TodoistTask): Task => ({
id: todoistTask.id,
content: todoistTask.content,
description: todoistTask.description,
priority: mapPriority(todoistTask.priority),
projectId: todoistTask.projectId,
parentTaskId: todoistTask.parentId ?? undefined,
isCompleted: todoistTask.isCompleted,
labels: todoistTask.labels,
createdAt: todoistTask.createdAt,
dueDate: _mapDueDate(todoistTask.due),
isRecurringDueDate: todoistTask.due?.isRecurring ?? false,
createdBy: todoistTask.creatorId,
assignee: todoistTask.assigneeId ?? undefined,
assigner: todoistTask.assignerId ?? undefined,
numberOfComments: todoistTask.commentCount,
positionWithinParent: todoistTask.order,
webUrl: todoistTask.url,
duration: todoistTask.duration ?? undefined,
sectionId: todoistTask.sectionId ?? undefined,
})
export const mapProject = (todoistProject: TodoistProject): Project => ({
id: todoistProject.id,
name: todoistProject.name,
parentProjectId: todoistProject.parentId ?? undefined,
positionWithinParent: todoistProject.order,
color: _mapColor(todoistProject.color),
isFavorite: todoistProject.isFavorite,
isInboxProject: todoistProject.isInboxProject,
isShared: todoistProject.isShared,
isTeamInbox: todoistProject.isTeamInbox,
numberOfComments: todoistProject.commentCount,
webUrl: todoistProject.url,
})
export const mapSection = (todoistSection: TodoistSection): Section => ({
id: todoistSection.id,
name: todoistSection.name,
projectId: todoistSection.projectId,
positionWithinParent: todoistSection.order,
})
export const mapTaskComment = (todoistComment: TodoistComment): Comment & PickRequired<Comment, 'taskId'> => ({
id: todoistComment.id,
postedAt: todoistComment.postedAt,
content: todoistComment.content,
taskId: todoistComment.taskId as string,
})
export const mapPriority = (priority: TodoistTask['priority']): Task['priority'] =>
reverseScale(priority, MIN_PRIORITY, MAX_PRIORITY)
const _mapDueDate = (dueDate: TodoistTask['due']) => dueDate?.datetime ?? dueDate?.date
const _mapColor = (color: TodoistProject['color']): Project['color'] =>
entities.Color.names.includes(color as Project['color']) ? (color as Project['color']) : 'charcoal'
}
@@ -0,0 +1,54 @@
import sdk, { z } from '@botpress/sdk'
import * as bp from '.botpress'
export const exchangeAuthCodeAndSaveRefreshToken = async ({
ctx,
client,
authorizationCode,
}: {
ctx: bp.Context
client: bp.Client
authorizationCode: string
}) => {
const accessToken = await _exchangeAuthCode({ authorizationCode })
if (!accessToken) {
throw new sdk.RuntimeError('Unable to obtain refresh token. Please try the OAuth flow again.')
}
await client.setState({
id: ctx.integrationId,
type: 'integration',
name: 'credentials',
payload: { accessToken },
})
}
export const getAccessToken = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }) =>
ctx.configurationType === 'apiToken' ? ctx.configuration.apiToken : await _getOAuthAccessToken({ client, ctx })
async function _getOAuthAccessToken({ client, ctx }: { client: bp.Client; ctx: bp.Context }) {
const { state } = await client.getState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
})
return state.payload.accessToken
}
const _exchangeAuthCode = async ({ authorizationCode }: { authorizationCode: string }) => {
const response = await fetch('https://todoist.com/oauth/access_token', {
method: 'POST',
body: JSON.stringify({
client_id: bp.secrets.CLIENT_ID,
client_secret: bp.secrets.CLIENT_SECRET,
code: authorizationCode,
}),
headers: { 'Content-Type': 'application/json' },
})
const { access_token } = z.object({ access_token: z.string() }).parse(await response.json())
return access_token
}
@@ -0,0 +1,84 @@
import { z } from '@botpress/sdk'
export class TodoistSyncClient {
private readonly _fetch: typeof fetch
public constructor({ accessToken }: { accessToken: string }) {
this._fetch = (input, init = {}) => {
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${accessToken}`)
return fetch(input, { ...init, headers })
}
}
public async getAuthenticatedUserIdentity() {
const identity = await this._get({
endpoint: 'user',
responseSchema: {
id: z.string(),
avatar_medium: z.string().optional(),
full_name: z.string().optional(),
},
})
return {
id: identity.id,
pictureUrl: identity.avatar_medium,
name: identity.full_name,
}
}
private async _post<T extends z.ZodRawShape>({
endpoint,
params,
responseSchema,
}: {
endpoint: string
responseSchema: T
params?: Record<string, any>
}): Promise<z.infer<z.ZodObject<T, 'strip'>>> {
return this._sendRequest({
endpoint,
init: {
method: 'POST',
body: JSON.stringify(params),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
responseSchema,
})
}
private async _get<T extends z.ZodRawShape>({
endpoint,
responseSchema,
}: {
endpoint: string
responseSchema: T
}): Promise<z.infer<z.ZodObject<T, 'strip'>>> {
return this._sendRequest({
endpoint,
init: {
method: 'GET',
},
responseSchema,
})
}
private async _sendRequest<T extends z.ZodRawShape>({
endpoint,
init,
responseSchema,
}: {
endpoint: string
init: RequestInit
responseSchema: T
}): Promise<z.infer<z.ZodObject<T, 'strip'>>> {
const response = await this._fetch(`https://api.todoist.com/sync/v9/${endpoint}`, init)
if (!response.ok) {
throw new Error(`Failed to send ${init.method} request to sync endpoint /${endpoint}: ${response.statusText}`)
}
return z.object(responseSchema).parse(await response.json())
}
}
@@ -0,0 +1,117 @@
import { TodoistApi } from '@doist/todoist-api-typescript'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import { RequestMapping, ResponseMapping } from './mapping'
import { exchangeAuthCodeAndSaveRefreshToken, getAccessToken } from './oauth-client'
import { TodoistSyncClient } from './sync-client'
import { CreateTaskRequest, Project, Task, UpdateTaskRequest, Section, GetAllTasksRequest } from './types'
import * as bp from '.botpress'
export class TodoistClient {
private readonly _todoistRestClient: TodoistApi
private readonly _todoistSyncClient: TodoistSyncClient
private constructor({ accessToken }: { accessToken: string }) {
this._todoistRestClient = new TodoistApi(accessToken)
this._todoistSyncClient = new TodoistSyncClient({ accessToken })
}
public static async create({ client, ctx }: { client: bp.Client; ctx: bp.Context }) {
const accessToken = await getAccessToken({ client, ctx })
return new TodoistClient({ accessToken })
}
@handleErrors('Failed to authenticate Todoist with authorization code')
public static async authenticateWithAuthorizationCode({
ctx,
client,
authorizationCode,
}: {
ctx: bp.Context
client: bp.Client
authorizationCode: string
}) {
await exchangeAuthCodeAndSaveRefreshToken({ ctx, client, authorizationCode })
}
@handleErrors('Failed to retrieve authenticated user')
public getAuthenticatedUserIdentity() {
return this._todoistSyncClient.getAuthenticatedUserIdentity()
}
public static getUserAvatarUrl({ imageId }: { imageId: string }) {
return `https://dcff1xvirvpfp.cloudfront.net/${imageId}_medium.jpg`
}
@handleErrors('Failed to find task by name')
public async getTaskByName({ name }: { name: string }): Promise<Task | null> {
const tasks = await this._todoistRestClient.getTasks({ filter: `search: ${name}` })
const matchingTask = tasks.find((task) => task.content === name)
return matchingTask ? ResponseMapping.mapTask(matchingTask) : null
}
@handleErrors('Failed to find task by ID')
public async getTaskById({ id }: { id: string }): Promise<Task> {
const task = await this._todoistRestClient.getTask(id)
return ResponseMapping.mapTask(task)
}
@handleErrors('Failed to find project by name')
public async getProjectByName({ name }: { name: string }): Promise<Project | null> {
const projects = await this._todoistRestClient.getProjects()
const matchingProject = projects.find((project) => project.name === name)
return matchingProject ? ResponseMapping.mapProject(matchingProject) : null
}
@handleErrors('Failed to get all projects')
public async getAllProjects(): Promise<Project[]> {
const projects = await this._todoistRestClient.getProjects()
return projects.map(ResponseMapping.mapProject)
}
@handleErrors('Failed to get all sections')
public async getAllSections({ projectId }: { projectId?: string }): Promise<Section[]> {
const sections = await this._todoistRestClient.getSections(projectId)
return sections.map(ResponseMapping.mapSection)
}
@handleErrors('Failed to get all tasks')
public async getAllTasks({ projectId, sectionId, labelName }: GetAllTasksRequest): Promise<Task[]> {
const tasks = await this._todoistRestClient.getTasks({ projectId, sectionId, label: labelName })
return tasks.map(ResponseMapping.mapTask)
}
@handleErrors('Failed to find project by ID')
public async getProjectById({ id }: { id: string }): Promise<Project> {
const project = await this._todoistRestClient.getProject(id)
return ResponseMapping.mapProject(project)
}
@handleErrors('Failed to create new task')
public async createNewTask({ task }: { task: CreateTaskRequest }): Promise<Task> {
const newTask = await this._todoistRestClient.addTask(RequestMapping.mapCreateTask(task))
return ResponseMapping.mapTask(newTask)
}
@handleErrors('Failed to update task')
public async updateTask({ task }: { task: UpdateTaskRequest }) {
const updatedTask = await this._todoistRestClient.updateTask(task.id, RequestMapping.mapUpdateTask(task))
return ResponseMapping.mapTask(updatedTask)
}
@handleErrors('Failed to comment on task')
public async commentOnTask({ taskId, content }: { taskId: string; content: string }) {
const newComment = await this._todoistRestClient.addComment({ taskId, content })
return ResponseMapping.mapTaskComment(newComment)
}
}
+51
View File
@@ -0,0 +1,51 @@
import {
Task as TaskEntity,
Project as ProjectEntity,
Comment as CommentEntity,
Section as SectionEntity,
} from 'definitions'
// Entities:
type Task = TaskEntity.InferredType
type BareMinimumTask = PartialExcept<Task, 'content'>
type Project = ProjectEntity.InferredType
type BareMinimumProject = PartialExcept<Project, 'name'>
type Section = SectionEntity.InferredType
type BareMinimumSection = PartialExcept<Section, 'name'>
type Comment = CommentEntity.InferredType
type BareMinimumComment = PartialExcept<Comment, 'content'>
// Action requests:
type CreateTaskRequest = Omit<
BareMinimumTask,
'id' | 'webUrl' | 'isCompleted' | 'isRecurringDueDate' | 'numberOfComments' | 'createdAt' | 'createdBy' | 'assigner'
>
type UpdateTaskRequest = Omit<
PartialExcept<Task, 'id'>,
| 'projectId'
| 'sectionId'
| 'isCompleted'
| 'parentTaskId'
| 'positionWithinParent'
| 'isRecurringDueDate'
| 'webUrl'
| 'numberOfComments'
| 'createdAt'
| 'createdBy'
| 'assigner'
>
type GetAllTasksRequest = {
projectId?: string
sectionId?: string
labelName?: string
}
// Type utilities:
/** Like Pick<T,K>, but each property is required */
type PickRequired<T, K extends keyof T> = { [P in K]-?: T[P] }
/** Makes all properties of T optional, except K, which are all required */
type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & PickRequired<T, K>
@@ -0,0 +1,125 @@
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<T extends Event> = (
props: bp.HandlerProps & { event: T; initiatorUserId: string }
) => Promise<void> | void
type WebhookEventHandlerEntry<T extends Event> = Readonly<[(event: Event) => event is T, WebhookEventHandler<T>]>
const EVENT_HANDLERS: Readonly<WebhookEventHandlerEntry<any>[]> = [
[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<string, string>)
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<boolean> => {
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<void> => {
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
}
@@ -0,0 +1,29 @@
import type { WebhookEventHandler } from '../handler-dispatcher'
import type { NoteAddedEvent, Event } from '../schemas'
export const isCommentAddedEvent = (event: Event): event is NoteAddedEvent => event.event_name === 'note:added'
export const handleCommentAddedEvent: WebhookEventHandler<NoteAddedEvent> = async ({
client,
event,
initiatorUserId,
}) => {
const conversationId = event.event_data.item_id
const commentId = event.event_data.id
const { conversation } = await client.getOrCreateConversation({
channel: 'comments',
tags: { id: conversationId },
})
await client.getOrCreateMessage({
tags: {
id: commentId,
},
type: 'text',
userId: initiatorUserId,
conversationId: conversation.id,
payload: {
text: event.event_data.content,
},
})
}
@@ -0,0 +1,49 @@
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
import { TodoistClient } from 'src/todoist-api'
import * as bp from '.botpress'
export const oauthCallbackHandler = async ({ client, ctx, req, logger }: bp.HandlerProps) => {
try {
const searchParams = new URLSearchParams(req.query)
const error = searchParams.get('error')
if (error) {
throw new Error(`${error} - ${searchParams.get('error_description') ?? ''}`)
}
const authorizationCode = searchParams.get('code')
if (!authorizationCode) {
throw new Error('Authorization code not present in OAuth callback')
}
await TodoistClient.authenticateWithAuthorizationCode({
client,
ctx,
authorizationCode,
})
const todoistClient = await TodoistClient.create({ client, ctx })
const userIdentity = await todoistClient.getAuthenticatedUserIdentity()
await client.configureIntegration({
identifier: userIdentity.id,
})
await client.updateUser({
id: ctx.botUserId,
name: userIdentity.name,
pictureUrl: userIdentity.pictureUrl,
tags: {
id: userIdentity.id,
},
})
logger.forBot().info('Successfully authenticated with Todoist')
return generateRedirection(getInterstitialUrl(true))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const errorMessage = 'OAuth error: ' + msg
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
@@ -0,0 +1,35 @@
import { ResponseMapping } from 'src/todoist-api/mapping'
import type { WebhookEventHandler } from '../handler-dispatcher'
import type { ItemUpdatedEvent, Event } from '../schemas'
export const isPriorityChangedEvent = (event: Event): event is ItemUpdatedEvent =>
event.event_name === 'item:updated' &&
event.event_data_extra?.update_intent === 'item_updated' &&
event.event_data_extra?.old_item?.priority !== event.event_data.priority
export const handlePriorityChangedEvent: WebhookEventHandler<ItemUpdatedEvent> = async ({
event,
client,
initiatorUserId,
}) => {
const newPriority = event.event_data.priority
const oldPriority = event.event_data_extra?.old_item.priority
const { conversation } = await client.getOrCreateConversation({
channel: 'comments',
tags: { id: event.event_data.id },
})
if (newPriority !== oldPriority) {
await client.createEvent({
type: 'taskPriorityChanged',
payload: {
id: event.event_data.id,
newPriority: ResponseMapping.mapPriority(newPriority),
oldPriority: oldPriority ? ResponseMapping.mapPriority(oldPriority) : undefined,
},
conversationId: conversation.id,
userId: initiatorUserId,
})
}
}
@@ -0,0 +1,29 @@
import { ResponseMapping } from 'src/todoist-api/mapping'
import type { WebhookEventHandler } from '../handler-dispatcher'
import type { ItemCompletedEvent, Event } from '../schemas'
export const isTaskCompletedEvent = (event: Event): event is ItemCompletedEvent => event.event_name === 'item:completed'
export const handleTaskCompletedEvent: WebhookEventHandler<ItemCompletedEvent> = async ({
event,
client,
initiatorUserId,
}) => {
const { conversation } = await client.getOrCreateConversation({
channel: 'comments',
tags: { id: event.event_data.id },
})
await client.createEvent({
type: 'taskCompleted',
payload: {
id: event.event_data.id,
user_id: event.event_data.user_id,
content: event.event_data.content,
description: event.event_data.description,
priority: ResponseMapping.mapPriority(event.event_data.priority),
},
conversationId: conversation.id,
userId: initiatorUserId,
})
}
@@ -0,0 +1,28 @@
import { ResponseMapping } from 'src/todoist-api/mapping'
import type { WebhookEventHandler } from '../handler-dispatcher'
import type { ItemAddedEvent, Event } from '../schemas'
export const isTaskCreatedEvent = (event: Event): event is ItemAddedEvent => event.event_name === 'item:added'
export const handleTaskCreatedEvent: WebhookEventHandler<ItemAddedEvent> = async ({
event,
client,
initiatorUserId,
}) => {
const { conversation } = await client.getOrCreateConversation({
channel: 'comments',
tags: { id: event.event_data.id },
})
await client.createEvent({
type: 'taskAdded',
payload: {
id: event.event_data.id,
content: event.event_data.content,
description: event.event_data.description,
priority: ResponseMapping.mapPriority(event.event_data.priority),
},
conversationId: conversation.id,
userId: initiatorUserId,
})
}
@@ -0,0 +1 @@
export * from './handler-dispatcher'
@@ -0,0 +1,76 @@
import { z } from '@botpress/sdk'
const baseEvent = z.object({
event_name: z
.enum(['note:added', 'item:added', 'item:completed', 'item:updated'])
.describe('The event name for the webhook'),
user_id: z.string().describe('The ID of the user that is the destination for the event.'),
event_data: z.object({}).describe('The data of the event.'),
initiator: z
.object({
id: z.string().describe('The ID of the user that initiated the event.'),
email: z.string().email().describe('The email of the user that initiated the event.'),
full_name: z.string().describe('The full name of the user that initiated the event.'),
image_id: z.string().nullable().describe('The image ID of the user that initiated the event.'),
})
.describe(
'The user that triggered the event. This may be the same user indicated in user_id or a collaborator from a shared project.'
),
})
export const noteEventDataSchema = z.object({
id: z.string(),
posted_uid: z.string(), // The ID of the user who posted the note
item_id: z.string(),
content: z.string(),
})
export type NoteAddedEvent = z.infer<typeof noteAddedEventSchema>
export const noteAddedEventSchema = baseEvent.extend({
event_name: z.literal('note:added'),
user_id: z.string(),
event_data: noteEventDataSchema,
})
export const itemEventDataSchema = z.object({
id: z.string(),
user_id: z.string(), // The owner of the task
content: z.string(),
description: z.string(),
priority: z.number(),
})
export type ItemAddedEvent = z.infer<typeof itemAddedEventSchema>
export const itemAddedEventSchema = baseEvent.extend({
event_name: z.literal('item:added'),
user_id: z.string(),
event_data: itemEventDataSchema,
})
export type ItemCompletedEvent = z.infer<typeof itemCompletedEventSchema>
export const itemCompletedEventSchema = baseEvent.extend({
event_name: z.literal('item:completed'),
user_id: z.string(),
event_data: itemEventDataSchema,
})
export type ItemUpdatedEvent = z.infer<typeof itemUpdatedEventSchema>
export const itemUpdatedEventSchema = baseEvent.extend({
event_name: z.literal('item:updated'),
user_id: z.string(),
event_data: itemEventDataSchema,
event_data_extra: z
.object({
old_item: itemEventDataSchema,
update_intent: z.enum(['item_updated', 'item_completed', 'item_uncompleted']),
})
.optional(), // Only present if updated by a user (not by the system)
})
export type Event = z.infer<typeof eventSchema>
export const eventSchema = z.union([
noteAddedEventSchema,
itemAddedEventSchema,
itemCompletedEventSchema,
itemUpdatedEventSchema,
])