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,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>