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
+3
View File
@@ -0,0 +1,3 @@
.botpress
node_modules
dist
+147
View File
@@ -0,0 +1,147 @@
import sdk, { z } from '@botpress/sdk'
import { Task, Project, Section, SharedLabel } from './entities'
export const actions = {
changeTaskPriority: {
title: 'Change Task Priority',
description: 'Change the priority of a task',
input: {
schema: z.object({
taskId: Task.schema.shape.id,
priority: Task.schema.shape.priority,
}),
},
output: {
schema: z.object({}),
},
},
getTaskId: {
title: 'Get Task ID',
description: 'Get the ID of the first task matching the given name',
input: {
schema: z.object({
name: z.string().title('Name').describe('The name of the task to search for'),
// NOTE: this actually refers to the `content` property of the Task
// entity: the `name` property does not exist
}),
},
output: {
schema: z.object({
taskId: Task.schema.shape.id.nullable(),
}),
},
},
getProjectId: {
title: 'Get Project ID',
description: 'Get the ID of the project',
input: {
schema: z.object({
name: Project.schema.shape.name,
}),
},
output: {
schema: z.object({
projectId: Project.schema.shape.id.nullable(),
}),
},
},
getAllProjects: {
title: 'Get All Projects',
description: 'Get all projects',
input: {
schema: z.object({}),
},
output: {
schema: z.object({
projects: z.array(Project.schema).title('Projects').describe('All projects that are available to the user'),
}),
},
},
getAllSections: {
title: 'Get All Sections',
description: 'Get all sections in a project',
input: {
schema: z.object({
projectId: Project.schema.shape.id
.optional()
.title('Project ID (optional)')
.describe('The ID of the project. If omitted, the sections of all projects will be returned.'),
}),
},
output: {
schema: z.object({
sections: z
.array(Section.schema)
.title('Sections')
.describe('All sections in the project, or all sections in all projects if projectId is omitted'),
}),
},
},
getAllTasks: {
title: 'Get All Tasks',
description: 'Find tasks using optional filters',
input: {
schema: z.object({
projectId: Project.schema.shape.id
.optional()
.title('Filter by Project ID (optional)')
.describe('The ID of the project. If omitted, tasks from all projects will be included in the results.'),
sectionId: Section.schema.shape.id
.optional()
.title('Filter by Section ID (optional)')
.describe('The ID of the section. If omitted, tasks from all sections will be included in the results.'),
labelName: SharedLabel.schema.shape.name
.optional()
.title('Filter by Label Name (optional)')
.describe('The name of the label. If omitted, tasks with any label will be included in the results.'),
}),
},
output: {
schema: z.object({
tasks: z.array(Task.schema).title('Tasks').describe('All matching tasks'),
}),
},
},
createNewTask: {
title: 'Create New Task',
description: 'Create a new task',
input: {
schema: z.object({
content: Task.schema.shape.content,
description: Task.schema.shape.description.optional(),
projectId: Project.schema.shape.id
.optional()
.title('Project ID (optional)')
.describe("The ID of the project. If omitted, the task will be added to the user's Inbox."),
sectionId: Section.schema.shape.id
.optional()
.title('Section ID (optional)')
.describe('The ID of section to put task into.'),
labelNames: z
.array(SharedLabel.schema.shape.name)
.optional()
.title('Label Names (optional)')
.describe('The names of the labels to add to the task. If omitted, no label will be added.'),
parentTaskId: Task.schema.shape.id
.optional()
.title('Parent Task ID (optional)')
.describe('The ID of the parent task. If omitted, the task will have no parent task.'),
priority: Task.schema.shape.priority
.optional()
.title('Priority (optional)')
.describe('The priority level of the task, from 1 (normal) to 4 (urgent).'),
dueDate: Task.schema.shape.dueDate
.optional()
.title('Due Date')
.describe(
'The due date of the task. Must be in RFC3339 format in UTC. If omitted, the task will have no due date.'
),
}),
},
output: {
schema: z.object({
taskId: Task.schema.shape.id,
}),
},
},
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,27 @@
import * as sdk from '@botpress/sdk'
export const channels = {
comments: {
title: 'Task comments',
description: 'Comment thread on a task',
messages: {
text: sdk.messages.defaults.text,
},
conversation: {
tags: {
id: {
title: 'Task ID',
description: 'The ID of the task',
},
},
},
message: {
tags: {
id: {
title: 'Comment ID',
description: 'The ID of the comment',
},
},
},
},
} as const satisfies sdk.IntegrationDefinitionProps['channels']
@@ -0,0 +1,23 @@
import sdk, { z } from '@botpress/sdk'
export const configuration = {
schema: z.object({}),
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
required: true,
},
} as const satisfies sdk.IntegrationDefinitionProps['configuration']
export const configurations = {
apiToken: {
title: 'Manual configuration',
description: 'Configure manually by supplying an API token',
schema: z.object({
apiToken: z.string().title('API Token').describe('The API token to authenticate with the Todoist API'),
}),
},
} satisfies sdk.IntegrationDefinitionProps['configurations']
export const identifier = {
extractScript: 'extract.vrl',
} as const satisfies sdk.IntegrationDefinitionProps['identifier']
@@ -0,0 +1,26 @@
// documentation for Color: https://developer.todoist.com/guides/#colors
export namespace Color {
export const names = [
'berry_red',
'red',
'orange',
'yellow',
'olive_green',
'lime_green',
'green',
'mint_green',
'teal',
'sky_blue',
'light_blue',
'blue',
'grape',
'violet',
'lavender',
'magenta',
'salmon',
'charcoal',
'gunmetal',
'taupe',
] as const
}
@@ -0,0 +1,27 @@
import { z } from '@botpress/sdk'
// documentation for Comment: https://developer.todoist.com/rest/v2/#comments
export namespace Comment {
const _fields = {
id: z.string().title('ID').describe('The ID of the comment'),
taskId: z
.string()
.title('Task ID')
.optional()
.describe('The ID of the task this comment belongs to. Omitted if the comment belongs to a project.'),
projectId: z
.string()
.title('Project ID')
.optional()
.describe('The ID of the project this comment belongs to. Omitted if the comment belongs to a task.'),
postedAt: z.string().title('Posted At').describe('Date and time when comment was added, RFC3339 format in UTC.'),
content: z
.string()
.title('Content')
.describe('The text of the comment. This value may contain markdown-formatted text and hyperlinks.'),
} as const
export const schema = z.object(_fields)
export type InferredType = z.infer<typeof schema>
}
@@ -0,0 +1,44 @@
import sdk from '@botpress/sdk'
import { Color } from './color'
import { Comment } from './comment'
import { PersonalLabel } from './personal-label'
import { Project } from './project'
import { Section } from './section'
import { SharedLabel } from './shared-label'
import { Task } from './task'
export { Comment, PersonalLabel, Project, Section, SharedLabel, Task, Color }
export const entities = {
task: {
title: 'Task',
description: 'A task in Todoist',
schema: Task.schema,
},
project: {
title: 'Project',
description: 'A project in Todoist',
schema: Project.schema,
},
section: {
title: 'Section',
description: 'A section in a Todoist project',
schema: Section.schema,
},
comment: {
title: 'Comment',
description: 'A comment on a Todoist task',
schema: Comment.schema,
},
personalLabel: {
title: 'Personal Label',
description: 'A personal label in Todoist',
schema: PersonalLabel.schema,
},
sharedLabel: {
title: 'Shared Label',
description: 'A shared label in Todoist',
schema: SharedLabel.schema,
},
} as const satisfies sdk.IntegrationDefinitionProps['entities']
@@ -0,0 +1,21 @@
import { z } from '@botpress/sdk'
import { Color } from './color'
// documentation for Label: https://developer.todoist.com/rest/v2/#labels
export namespace PersonalLabel {
const _fields = {
id: z.string().title('ID').describe('The ID of the label.'),
name: z.string().title('Name').describe('The name of the label.'),
color: z.enum(Color.names).title('Color Name').describe('The color of the label.'),
orderWithinList: z
.number()
.title('Order Within List')
.nonnegative()
.describe("Numerical index indicating label's order within the user's label list."),
isFavorite: z.boolean().title('Is Favorite?').describe('Whether the label is marked as favorite or not.'),
} as const
export const schema = z.object(_fields)
export type InferredType = z.infer<typeof schema>
}
@@ -0,0 +1,36 @@
import { z } from '@botpress/sdk'
import { Color } from './color'
// documentation for Project: https://developer.todoist.com/rest/v2/#projects
export namespace Project {
const _fields = {
id: z.string().title('ID').describe('The ID of the project.'),
name: z.string().title('Name').describe('The name of the project.'),
color: z.enum(Color.names).title('Color Name').describe('The color of the project.'),
parentProjectId: z
.string()
.title('Parent Project ID')
.optional()
.describe('The ID of the parent project. Omitted if the project has no parent project.'),
positionWithinParent: z
.number()
.title('Position Within Parent')
.describe(
"Numerical index indicating project's order within its parent project (read-only). Will be 0 for inbox projects."
),
numberOfComments: z
.number()
.title('Number of Comments')
.nonnegative()
.describe('The number of comments on the project.'),
isShared: z.boolean().title('Is Shared?').describe('Whether the project is shared or not.'),
isFavorite: z.boolean().title('Is Favorite?').describe('Whether the project is marked as favorite or not.'),
isInboxProject: z.boolean().title('Is Inbox Project?').describe("Whether the project is the user's inbox."),
isTeamInbox: z.boolean().title('Is Team Inbox?').describe('Whether the project is a team inbox.'),
webUrl: z.string().title('Web URL').describe('The URL of the project in the Todoist web app.'),
} as const
export const schema = z.object(_fields)
export type InferredType = z.infer<typeof schema>
}
@@ -0,0 +1,18 @@
import { z } from '@botpress/sdk'
// documentation for Section: https://developer.todoist.com/rest/v2/#sections
export namespace Section {
const _fields = {
id: z.string().title('ID').describe('The ID of the section.'),
name: z.string().title('Name').describe('The name of the section.'),
projectId: z.string().title('Project ID').optional().describe('The ID of the project this section belongs to.'),
positionWithinParent: z
.number()
.title('Position Within Parent')
.describe("Numerical index indicating section's order within its parent project."),
} as const
export const schema = z.object(_fields)
export type InferredType = z.infer<typeof schema>
}
@@ -0,0 +1,25 @@
import { z } from '@botpress/sdk'
// documentation for Label: https://developer.todoist.com/rest/v2/#labels
/**
* Shared labels are ephemeral in nature. They only exist as long as at least
* one task is associated with them. Once all tasks are removed from a shared
* label, the label ceases to exist.
*
* A user can convert a shared label to a personal label at any time. The label
* will then become customizable and will remain in the account even if not
* assigned to any active tasks.
*
* Likewise, a personal label can be converted to a shared label by the user if
* they no longer require them to be stored against their account, but they
* still appear on shared tasks.
*/
export namespace SharedLabel {
const _fields = {
name: z.string().title('Name').describe('The name of the label.'),
} as const
export const schema = z.object(_fields)
export type InferredType = z.infer<typeof schema>
}
@@ -0,0 +1,98 @@
import { z } from '@botpress/sdk'
/**
* Note: `item` is an old term used by Todoist to refer to tasks in their old
* Sync API. While the REST API uses the term `task` and sends us Task entities,
* we still receive Item entities in webhook events. Todoist is currently in the
* process of phasing out the Item entity in favor of the Task entity, but for
* now we must manually do some mapping between the two, since we only want to
* expose the Task entity to the user.
*/
// documentation for Task: https://developer.todoist.com/rest/v2/#tasks
// documentation for Item: https://developer.todoist.com/sync/v9/#items
export namespace Task {
const _fields = {
id: z.string().title('ID').describe('The ID of the task'),
projectId: z.string().title('Project ID').describe('The ID of the project this task belongs to (read-only).'),
sectionId: z
.string()
.title('Section ID')
.optional()
.describe('The ID of the section this task belongs to (read-only). Omitted if the task is not in a section.'),
content: z
.string()
.title('Content')
.describe('The text of the task. This value may contain markdown-formatted text and hyperlinks.'),
description: z
.string()
.title('Description')
.describe('A description for the task. This value may contain markdown-formatted text and hyperlinks.'),
isCompleted: z.boolean().title('Is Completed?').describe('Whether the task is completed or not.'),
labels: z
.array(z.string().title('Label Name').describe('The name of a label associated with the task.'))
.title('Labels')
.describe(
'The labels associated with the task. This is a list of names that may represent either personal or shared labels.'
),
parentTaskId: z
.string()
.title('Parent Task ID')
.optional()
.describe('The ID of the parent task (read-only). Omitted if the task has no parent task.'),
positionWithinParent: z
.number()
.title('Position Within Parent')
.describe(
"Numerical index indicating task's order within its parent (task, or project for top-level tasks) (read-only)."
),
priority: z
.number()
.title('Priority')
.min(1)
.max(4)
.describe('Task priority from 1 (urgent) to 4 (lowest, default value).'),
dueDate: z
.string()
.title('Due Date')
.optional()
.describe('The due date of the task. Omitted if the task has no due date.'),
isRecurringDueDate: z
.boolean()
.title('Is Recurring Due Date?')
.describe('Whether the due date is recurring or not.'),
webUrl: z
.string()
.title('Web URL')
.describe('URL to access this task in the Todoist web or mobile applications (read-only).'),
numberOfComments: z
.number()
.title('Number of Comments')
.nonnegative()
.describe('The number of comments associated with the task (read-only).'),
createdAt: z.string().title('Created At').describe('The date when the task was created (read-only).'),
createdBy: z.string().title('Created By ID').describe('The ID of the user who created the task (read-only).'),
assignee: z
.string()
.title('Assignee ID')
.optional()
.describe('The ID of the user to whom the task is assigned. Omitted if the task is not assigned.'),
assigner: z
.string()
.title('Assigner ID')
.optional()
.describe('The ID of the user who assigned the task (read-only). Omitted if the task is not assigned.'),
duration: z
.object({
amount: z.number().title('Amount').positive().describe('The amount of time.'),
unit: z.enum(['minute', 'day']).title('Unit').describe('The unit of time.'),
})
.title('Duration')
.optional()
.describe('The duration of the task. Omitted if the task has no duration.'),
} as const
export const schema = z.object(_fields)
export type InferredType = z.infer<typeof schema>
}
@@ -0,0 +1,36 @@
import sdk, { z } from '@botpress/sdk'
import { Task } from './entities'
export const events = {
taskAdded: {
title: 'Task Added',
description: 'A task has been added',
schema: z.object({
id: Task.schema.shape.id,
content: Task.schema.shape.content,
description: Task.schema.shape.description,
priority: Task.schema.shape.priority,
}),
},
taskPriorityChanged: {
title: 'Task Priority Changed',
description:
'The priority of a task has been changed. The old priority is only available if the bot user is at the origin of the change',
schema: z.object({
id: Task.schema.shape.id,
newPriority: Task.schema.shape.priority.title('New Priority'),
oldPriority: Task.schema.shape.priority.optional().title('Old Priority'),
}),
},
taskCompleted: {
title: 'Task Completed',
description: 'A task has been completed',
schema: z.object({
user_id: z.string().title('User ID').describe('The ID of the user who completed the task'),
id: Task.schema.shape.id,
content: Task.schema.shape.content,
description: Task.schema.shape.description,
priority: Task.schema.shape.priority,
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['events']
@@ -0,0 +1,8 @@
export * from './actions'
export * from './channels'
export * from './configuration'
export * from './entities'
export * from './events'
export * from './secrets'
export * from './states'
export * from './user-tags'
@@ -0,0 +1,16 @@
import sdk from '@botpress/sdk'
export const secrets = {
CLIENT_ID: {
optional: false,
description: 'Client ID in the App Management page of your Todoist app',
},
CLIENT_SECRET: {
optional: false,
description: 'Client Secret in the App Management page of your Todoist app',
},
WEBHOOK_SHARED_SECRET: {
optional: false,
description: 'Webhook shared secret',
},
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
@@ -0,0 +1,21 @@
import sdk, { z } from '@botpress/sdk'
export const states = {
credentials: {
type: 'integration',
schema: z.object({
accessToken: z
.string()
.title('Access Token')
.describe('The access token used to communicate with the Todoist API'),
}),
},
// TODO: This state is unused. Delete it in the next major version.
configuration: {
type: 'integration',
schema: z.object({
botUserId: z.string().optional().title('Bot User ID').describe('The ID of the bot user in Todoist'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['states']
@@ -0,0 +1,10 @@
import sdk from '@botpress/sdk'
export const user = {
tags: {
id: {
title: 'Todoist User ID',
description: 'The unique ID of the user on Todoist',
},
},
} as const satisfies sdk.IntegrationDefinitionProps['user']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+1
View File
@@ -0,0 +1 @@
parse_json!(.body).user_id
+44
View File
@@ -0,0 +1,44 @@
Integrate your chatbot with Todoist to create and modify tasks, post comments, and more.
## Migrating from version `0.x` to `1.x`
If you are migrating from version `0.x` to `1.x`, please note the following breaking changes:
> The "Task Create" action has been replaced with the "Create New Task" action.
## Configuration
### Automatic configuration with OAuth
To set up the Todoist integration using OAuth, click the authorization button and follow the on-screen instructions to connect your Botpress chatbot to Todoist.
When configuring your bot with OAuth, you can either log in with your user account or with a user account you created specifically for your bot.
Please keep in mind that if you log in with your user account, the bot actions and comments will appear as yours.
For most use cases, it is recommended to create a user account specifically for your bot. You will have to invite the bot's user to a shared project for it to be able to post comment, do actions, etc.
### Manual configuration using a personal API token
1. Todoist App creation
- Create an app in the [App Management page](https://developer.todoist.com/appconsole.html).
- Copy your user's personnal API token or generate a test token in the App Management page.
2. Todoist Botpress integration configuration
- Install the Todoist integration in your Botpress bot.
- Paste the API token copied earlier in the configuration fields. This is the token your bot will use to post comments, update or create tasks, etc.
- Save configuration.
- Copy the Webhook URL of your bot.
3. Todoist App Webhook configuration
- Go in the App Management page of your app on Todoist.
- Make sure the Webhooks events are activated. Follow [these instructions](https://developer.todoist.com/sync/v9/#webhooks) provided by Todoist to do so.
- Paste the Webhook URL copied earlier in the _Webhook callback URL_ field.
- Check the following _Watched Events_:
- _item:added_;
- _item:updated_;
- _item:completed_;
- _note:added_.
- Save the Webhook configuration.
## Limitations
Standard Todoist API limitations apply to the Todoist integration in Botpress. These limitations include rate limits, payload size restrictions, and other constraints imposed by Todoist. Ensure that your chatbot adheres to these limitations to maintain optimal performance and reliability.
More details are available in the [Todoist Developer Documentation](https://developer.todoist.com/rest/v2/#request-limits).
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px" height="800px" viewBox="0 0 256 256" version="1.1" preserveAspectRatio="xMidYMid">
<g>
<path d="M224.001997,0 L31.9980026,0 C14.3579381,0.0394964443 0.0614809418,14.336846 0,32 L0,224 C0,241.6 14.3971038,256 31.9980026,256 L224.001997,256 C241.602896,256 256,241.6 256,224 L256,32 C256,14.4 241.602896,0 224.001997,0" fill="#E44332">
</path>
<path d="M54.132778,120.802491 C58.5960224,118.196275 154.476075,62.477451 156.667847,61.1862981 C158.859619,59.9110855 158.97917,55.9898065 156.508446,54.5711324 C154.053661,53.1604284 149.391165,50.4824817 147.661658,49.4543415 C145.192242,48.0957707 142.191169,48.132074 139.755339,49.5499825 C138.527947,50.2672896 56.6035026,97.8486625 53.8697654,99.4107981 C50.5781227,101.291737 46.5372925,101.323617 43.2695601,99.4107981 L0,74.0181257 L0,95.6011002 C10.5205046,101.801822 36.7181549,117.200015 43.062338,120.826401 C46.8481256,122.978322 50.4745117,122.930502 54.1407481,120.802491" fill="#FFFFFF">
</path>
<path d="M54.132778,161.609296 C58.5960224,159.00308 154.476075,103.284257 156.667847,101.993104 C158.859619,100.717891 158.97917,96.7966121 156.508446,95.377938 C154.053661,93.9672339 149.391165,91.2892873 147.661658,90.2611471 C145.192242,88.9025763 142.191169,88.9388796 139.755339,90.3567881 C138.527947,91.0740952 56.6035026,138.655468 53.8697654,140.217604 C50.5781227,142.098542 46.5372925,142.130423 43.2695601,140.217604 L0,114.824931 L0,136.407906 C10.5205046,142.608627 36.7181549,158.00682 43.062338,161.633206 C46.8481256,163.785128 50.4745117,163.737307 54.1407481,161.609296" fill="#FFFFFF">
</path>
<path d="M54.132778,204.966527 C58.5960224,202.360311 154.476075,146.641487 156.667847,145.350335 C158.859619,144.075122 158.97917,140.153843 156.508446,138.735169 C154.053661,137.324465 149.391165,134.646518 147.661658,133.618378 C145.192242,132.259807 142.191169,132.29611 139.755339,133.714019 C138.527947,134.431326 56.6035026,182.012699 53.8697654,183.574835 C50.5781227,185.455773 46.5372925,185.487654 43.2695601,183.574835 L0,158.182162 L0,179.765137 C10.5205046,185.965858 36.7181549,201.364051 43.062338,204.990437 C46.8481256,207.142359 50.4745117,207.094538 54.1407481,204.966527" fill="#FFFFFF">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,39 @@
import * as sdk from '@botpress/sdk'
import {
actions,
channels,
configuration,
configurations,
entities,
events,
identifier,
secrets,
states,
user,
} from './definitions'
export default new sdk.IntegrationDefinition({
name: 'todoist',
title: 'Todoist',
description: 'Create and modify tasks, post comments and more.',
version: '1.0.7',
readme: 'hub.md',
icon: 'icon.svg',
actions,
channels,
configuration,
configurations,
entities,
events,
identifier,
secrets,
states,
user,
__advanced: {
useLegacyZuiTransformer: true,
},
attributes: {
category: 'Project Management',
repo: 'botpress',
},
})
+16
View File
@@ -0,0 +1,16 @@
webhookId = to_string!(.webhookId)
env = to_string!(.env)
clientId = "b3d967c0a27f4f08aaa64a5e4ac3cc62"
if env == "production" {
clientId = "58baef132a1d4eff9c4afaafdca85299"
}
scopes = [
"data:read_write",
"data:delete",
]
scopesStr = encode_percent(join!(scopes, ","))
"https://todoist.com/oauth/authorize?client_id={{ clientId }}&state={{ webhookId }}&scope={{ scopesStr }}"
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@botpresshub/todoist",
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"@doist/todoist-api-typescript": "^3.0.3"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpresshub/creatable": "workspace:*"
},
"bpDependencies": {}
}
@@ -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,
])
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config