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