chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
)
|
||||
@@ -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 } })
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
])
|
||||
Reference in New Issue
Block a user