diff --git a/README.md b/README.md index 422441d..b0da0e4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ The first version of this bot was developed by following the steps provided in [ - `/todo [#tag [#...]]` will add a ToDo/task to TickTick's inbox, for sorting - `/today [#tag [#...]]` will add a ToDo/task to TickTick, due today +- `/next` will list the next `task` for each Trello card - `/next [#tag]` will add a `task` to the top of the check-list of the Trello card associated with `#tag` - `/note [#card [#...]]` will add a comment to the specified Trello card(s), for journaling - `/shelf ` will propose the addition of an album to the [adrienjoly/album-shelf](https://github.com/adrienjoly/album-shelf) GitHub repository (requires options: `spotify.clientid`, `spotify.secret` and `github.token` with "public repo" permissions) @@ -115,10 +116,33 @@ You can troubleshoot your bot using [your firebase console](https://console.fire Set `telegram.onlyfromuserid` in your `.config.json` file and call `$ npm run deploy` again if you want the bot to only respond to that Telegram user identifier. +## How to add a command + +The steps are listed in the order I usually follow: + +1. In the `commandHandlers` array of `src/messageHandler.ts`, add an entry for your command. At first, make it return a simple `string`, like we did for the `/version` command. Deploy it and test it in production, just to make sure that you won't be blocked later at that critical step. + +2. Write an automated test in `src/use-cases/`, to define the expected reponse for a sample command. (see [example](https://github.com/adrienjoly/telegram-scribe-bot/pull/24/commits/d52320b905ad9392472dd28f26abbb4fdc07ee8e)) + +3. Write a minimal `CommandHandler`, just to make the test pass, without calling any 3rd-party API yet. (see [example](https://github.com/adrienjoly/telegram-scribe-bot/pull/24/commits/cfc22c626b58c5e268d825aa1c2fff691ff16228)) + +4. Write a small tool to examine the response from the 3rd-party API. (see [example](https://github.com/adrienjoly/telegram-scribe-bot/pull/24/commits/792fbf7d669e8386d5e17c8f50b23623156b99f9)) + +5. Update the implementation of your `CommandHandler`, so it relies on the actual API response. Make sure that the test passes, when you provide your API credentials. (see [example](https://github.com/adrienjoly/telegram-scribe-bot/pull/24/commits/565cb21a10b8cfd1e44390227976541e62439d2c)) + +6. Make the automated test mock the API request(s) so that it doesn't require API credentials to run. (see [example](https://github.com/adrienjoly/telegram-scribe-bot/pull/24/commits/b3f4a23a375c49fe152735df41bafef880b77abc)) + + > In that step, you can leverage the `⚠ no match for [...]` logs displayed when running your test from step 5, in order to know which URL(s) to mock. + +7. Test your command locally, using `$ npm run test:bot`. + +8. Deploy and test your command in production, as explained above. + ## ToDo / Next steps - Make setup easier and faster, e.g. by automatizing some of the steps - ideas of "command" use cases to implement: + - `/next [#tag]` will list the next `task` for each Trello card associated with `#tag` - `/search [#tag [#...]]` will search occurrences of `text` in comments of Trello cards, optionally filtered by `#tags` - `/openwhyd [#tag] [desc]` will add a music track (e.g. YouTube URL) to Openwhyd.org, in a playlist corresponding to the `tag`, and may add a `desc`ription if provided - `/issue ` will create a github issue on the provided repo diff --git a/functions/.vscode/settings.json b/functions/.vscode/settings.json index 9a8005b..a1cb3d0 100644 --- a/functions/.vscode/settings.json +++ b/functions/.vscode/settings.json @@ -4,5 +4,13 @@ "source.fixAll.eslint": true }, "prettier.configPath": ".prettierrc.js", - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "lib/": true, + } } diff --git a/functions/package.json b/functions/package.json index 260d2e3..4c8b798 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "scripts": { "lint": "npx eslint .", "lint:fix": "npx eslint . --fix", - "test": "ts-mocha -p ./tsconfig.json ./test/*.test.ts", + "test": "ts-mocha -p ./tsconfig.json './**/*.test.ts'", "test:bot": "ts-node tools/bot-cli.ts", "start": "ts-node tools/start.ts", "clean": "rm -rf lib", @@ -18,13 +18,7 @@ "deploy:test": "source ../.env && curl -X POST -H \"Content-Type:application/json\" ${ROUTER_URL} -d '{}'", "webhook:bind": "source ../.env && curl https://api.telegram.org/bot${BOT_TOKEN}/setWebhook?url=${ROUTER_URL}", "webhook:test": "source ../.env && curl https://api.telegram.org/bot${BOT_TOKEN}/getWebhookInfo", - "logs": "firebase functions:log", - "spotify:test": "ts-node tools/spotify-album.ts", - "github:pr:test": "ts-node tools/github-pr.ts", - "ticktick:test": "ts-node tools/ticktick.ts", - "trello:test": "source ../.env && curl -LI \"https://api.trello.com/1/members/me/boards?key=${TRELLO_API_KEY}&token=${TRELLO_USER_TOKEN}\" -s | grep HTTP", - "trello:checklist": "ts-node tools/trello-checklist.ts", - "trello:boards": "ts-node tools/trello-boards.ts" + "logs": "firebase functions:log" }, "engines": { "node": "12" diff --git a/functions/test/app.test.ts b/functions/src/app.test.ts similarity index 98% rename from functions/test/app.test.ts rename to functions/src/app.test.ts index 3354331..83a3b18 100644 --- a/functions/test/app.test.ts +++ b/functions/src/app.test.ts @@ -2,7 +2,7 @@ import expect from 'expect' import fetch from 'node-fetch' -import { startApp } from './../src/app' +import { startApp } from './app' const options = {} diff --git a/functions/src/messageHandler.ts b/functions/src/messageHandler.ts index d3d5ebd..7ec1aa3 100644 --- a/functions/src/messageHandler.ts +++ b/functions/src/messageHandler.ts @@ -4,7 +4,10 @@ import { addTaskToTicktick, addTodayTaskToTicktick, } from './use-cases/addTaskToTicktick' -import { addAsTrelloComment, addAsTrelloTask } from './use-cases/addToTrello' +import { + addAsTrelloComment, + getOrAddTrelloTasks, +} from './use-cases/addToTrello' import { addSpotifyAlbumToShelfRepo } from './use-cases/addSpotifyAlbumToShelfRepo' import { BotResponse } from './types' @@ -14,7 +17,7 @@ const commandHandlers: { [key: string]: CommandHandler } = { '/todo': addTaskToTicktick, '/today': addTodayTaskToTicktick, '/note': addAsTrelloComment, - '/next': addAsTrelloTask, + '/next': getOrAddTrelloTasks, '/version': async (_, options): Promise => { return { text: `ℹ️ Version: ${options.bot.version}` } }, @@ -44,7 +47,11 @@ export async function processMessage( commandHandlers ).join(', ')}` } else { - text = (await commandHandler(entities, options)).text + const res = await commandHandler(entities, options) + if (res.error) { + console.error(res.error) + } + text = res.text } } catch (err) { text = `😕 Error while processing: ${err.message}` diff --git a/functions/src/services/Trello.ts b/functions/src/services/Trello.ts index 5b35a00..8ed246e 100644 --- a/functions/src/services/Trello.ts +++ b/functions/src/services/Trello.ts @@ -29,6 +29,15 @@ export class Trello { )) as TrelloChecklist } + async getNextTodoItem(checklistId: string): Promise { + const { checkItems } = await this.getChecklist(checklistId) + return checkItems + .filter((a: TrelloChecklistItem) => a.state === 'incomplete') + .sort( + (a: TrelloChecklistItem, b: TrelloChecklistItem) => a.pos - b.pos + )[0] + } + async addComment(cardId: string, { text }: { text: string }) { return await this.trelloLib.makeRequest( 'post', diff --git a/functions/src/types.ts b/functions/src/types.ts index c029106..4fa0429 100644 --- a/functions/src/types.ts +++ b/functions/src/types.ts @@ -7,10 +7,11 @@ export type MessageHandlerOptions = { export type CommandHandler = ( message: ParsedMessageEntities, options: MessageHandlerOptions -) => Promise<{ text: string }> +) => Promise export type BotResponse = { text: string + error?: Error } export type TelegramRequest = { diff --git a/functions/test/spotify.test.ts b/functions/src/use-cases/addSpotifyAlbumToShelfRepo.test.ts similarity index 96% rename from functions/test/spotify.test.ts rename to functions/src/use-cases/addSpotifyAlbumToShelfRepo.test.ts index 3350bf1..5a0bb62 100644 --- a/functions/test/spotify.test.ts +++ b/functions/src/use-cases/addSpotifyAlbumToShelfRepo.test.ts @@ -6,8 +6,8 @@ import { Options, parseAlbumId, addSpotifyAlbumToShelfRepo, -} from './../src/use-cases/addSpotifyAlbumToShelfRepo' -import { ParsedMessageEntities } from './../src/Telegram' +} from './addSpotifyAlbumToShelfRepo' +import { ParsedMessageEntities } from '../Telegram' const FAKE_CREDS: Options = { spotify: { diff --git a/functions/src/use-cases/addToTrello.test.ts b/functions/src/use-cases/addToTrello.test.ts new file mode 100644 index 0000000..527749f --- /dev/null +++ b/functions/src/use-cases/addToTrello.test.ts @@ -0,0 +1,380 @@ +/// + +import expect from 'expect' +import nock from 'nock' +import { + TrelloOptions, + addAsTrelloComment, + addAsTrelloTask, + getNextTrelloTasks, +} from './addToTrello' +import { ParsedMessageEntities } from '../Telegram' + +const FAKE_CREDS: TrelloOptions = { + trello: { + apikey: 'trelloApiKey', + boardid: 'trelloBoardId', + usertoken: 'trelloUserToken', + }, +} + +const trelloCardWithTag = (tag: string) => ({ + id: 'myCardId', + name: `Dummy card associated with ${tag}`, + desc: `telegram-scribe-bot:addCommentsFromTaggedNotes(${tag})`, +}) + +const createMessage = ({ ...overrides }): ParsedMessageEntities => ({ + date: new Date(), + commands: [], + tags: [], + rest: '', + ...overrides, +}) + +const mockTrelloBoard = (boardId: string, cards: Partial[]) => + nock('https://api.trello.com') + .get(`/1/boards/${boardId}/cards`) + .query(true) + .reply(200, cards) + +const mockTrelloCard = (boardId: string, card: Partial) => + nock('https://api.trello.com') + .get(`/1/boards/${boardId}/cards/${card.id}`) + .query(true) + .reply(200, card) + +const mockTrelloChecklist = (checklist: Partial) => + nock('https://api.trello.com') + .get(`/1/checklists/${checklist.id}`) + .query(true) + .reply(200, checklist) + +// simulate the response of adding a comment to a card +const mockTrelloComment = () => + nock('https://api.trello.com') + .post((uri) => uri.includes('/actions/comments')) + .query(true) + .reply(200, {}) + +describe('trello use cases', () => { + before(() => { + nock.emitter.on('no match', ({ method, path }) => + console.warn(`⚠ no match for ${method} ${path}`) + ) + }) + + after(() => { + nock.cleanAll() + nock.enableNetConnect() + }) + + beforeEach(() => { + nock.cleanAll() + }) + + describe('(shared behaviors)', () => { + it('fails if trello credentials are not provided', async () => { + const message = createMessage({ rest: 'coucou' }) + const promise = addAsTrelloComment(message, {}) + expect(promise).rejects.toThrow('missing trello.apikey') + }) + + it('fails if trello credentials are empty', async () => { + const message = createMessage({ rest: 'coucou' }) + const options: TrelloOptions = { + trello: { + apikey: '', + boardid: '', + usertoken: '', + }, + } + const promise = addAsTrelloComment(message, options) + expect(promise).rejects.toThrow('missing trello.apikey') + }) + + it('suggests existing tags if no tags were provided', async () => { + const tags = ['#card1tag', '#card2tag'] + const cards = tags.map((tag) => trelloCardWithTag(tag)) + mockTrelloBoard(FAKE_CREDS.trello.boardid, cards) + const message = createMessage({ rest: 'coucou' }) + const res = await addAsTrelloComment(message, FAKE_CREDS) + expect(res.text).toMatch('Please specify at least one hashtag') + expect(res.text).toMatch(tags[0]) + expect(res.text).toMatch(tags[1]) + }) + + it('suggests existing tags if no card matches the tag', async () => { + const tagName = '#anActualTag' + mockTrelloBoard(FAKE_CREDS.trello.boardid, [trelloCardWithTag(tagName)]) + const message = createMessage({ + rest: 'coucou', + commands: [{ type: 'bot_command', text: '/note' }], + tags: [{ type: 'hashtag', text: '#aRandomTag' }], + }) + const res = await addAsTrelloComment(message, FAKE_CREDS) + expect(res.text).toMatch('No cards match') + expect(res.text).toMatch('Please pick another tag') + expect(res.text).toMatch(tagName.toLowerCase()) + }) + + it('tolerates cards that are not associated with a tag', async () => { + mockTrelloBoard(FAKE_CREDS.trello.boardid, [ + trelloCardWithTag('#anActualTag'), + { id: 'cardWithoutTag', name: `Card without tag`, desc: `` }, + ]) + const message = createMessage({ + rest: 'coucou', + commands: [{ type: 'bot_command', text: '/note' }], + tags: [{ type: 'hashtag', text: '#aRandomTag' }], + }) + const res = await addAsTrelloComment(message, FAKE_CREDS) + expect(res.text).toMatch('No cards match') + }) + + it('invites to bind tags to card, if none were found', async () => { + mockTrelloBoard(FAKE_CREDS.trello.boardid, [ + { id: 'cardWithoutTag', name: `Card without tag`, desc: `` }, + ]) + const message = createMessage({ + rest: 'coucou', + commands: [{ type: 'bot_command', text: '/note' }], + tags: [{ type: 'hashtag', text: '#aRandomTag' }], + }) + const res = await addAsTrelloComment(message, FAKE_CREDS) + expect(res.text).toMatch('Please bind tags to your cards') + }) + }) + + describe('addAsTrelloComment', () => { + it('succeeds', async () => { + const tagName = '#myTag' + // run test + mockTrelloBoard(FAKE_CREDS.trello.boardid, [trelloCardWithTag(tagName)]) + mockTrelloComment() + const message = createMessage({ + rest: 'coucou', + commands: [{ type: 'bot_command', text: '/note' }], + tags: [{ type: 'hashtag', text: tagName }], + }) + const res = await addAsTrelloComment(message, FAKE_CREDS) + // check expectations + expect(res.text).toMatch('Sent to Trello cards') + expect(res.text).toMatch(tagName) + }) + + it('succeeds if tag is specified without hash, in the card', async () => { + const tagName = 'myTag' + // run test + mockTrelloBoard(FAKE_CREDS.trello.boardid, [trelloCardWithTag(tagName)]) + mockTrelloComment() + const message = createMessage({ + rest: 'coucou', + commands: [{ type: 'bot_command', text: '/note' }], + tags: [{ type: 'hashtag', text: `#${tagName}` }], + }) + const res = await addAsTrelloComment(message, FAKE_CREDS) + // check expectations + expect(res.text).toMatch('Sent to Trello cards') + expect(res.text).toMatch(tagName) + }) + }) + + describe('addAsTrelloTask', () => { + it('fails if matching card has no checklist', async () => { + // run test + const tagName = '#myTag' + const card = trelloCardWithTag(tagName) + mockTrelloBoard(FAKE_CREDS.trello.boardid, [card]) + mockTrelloCard(FAKE_CREDS.trello.boardid, { ...card, idChecklists: [] }) // simulate the absence of checklists in that trello card + const message = createMessage({ + rest: 'coucou', + commands: [{ type: 'bot_command', text: '/note' }], + tags: [{ type: 'hashtag', text: tagName }], + }) + const res = await addAsTrelloTask(message, FAKE_CREDS) + // check expectations + expect(res.text).toMatch('No checklists were found for these tags') + }) + + it('succeeds', async () => { + const tagName = '#myTag' + const card = trelloCardWithTag(tagName) + // run test + const checklistId = 'myChecklistId' + mockTrelloBoard(FAKE_CREDS.trello.boardid, [card]) + mockTrelloCard(FAKE_CREDS.trello.boardid, { + ...card, + idChecklists: [checklistId], + }) + mockTrelloChecklist({ id: checklistId, name: 'My checklist' }) + nock('https://api.trello.com') // simulate the response of adding a task to that checklist + .post((uri) => uri.includes(`/1/checklists/${checklistId}/checkitems`)) + .reply(200) + const message = createMessage({ + rest: 'coucou', + commands: [{ type: 'bot_command', text: '/next' }], + tags: [{ type: 'hashtag', text: tagName }], + }) + const res = await addAsTrelloTask(message, FAKE_CREDS) + // check expectations + expect(res.text).toMatch('Added task at the top of these Trello cards') + expect(res.text).toMatch(tagName) + expect(res.text).toMatch(card.name) + }) + }) + + describe('getNextTrelloTasks', () => { + it('returns the first incomplete task of the only card of a board', async () => { + const cardName = `🌿 Santé` + const expectedNextStep = 'prendre rdv checkup dentiste' + const expectedResult = `${cardName}: ${expectedNextStep}` + const checklistItems = [ + { + pos: 3, + state: 'incomplete', + name: 'faire bilan santé', + }, + { + pos: 1, + state: 'complete', + name: 'prendre rdv checkup médecin traitant', + }, + { + pos: 2, + state: 'incomplete', + name: expectedNextStep, + }, + ] + // run test + const card = { ...trelloCardWithTag('someTag'), name: cardName } + const checklist = { + id: 'myChecklistId', + checkItems: checklistItems as TrelloChecklistItem[], + } + mockTrelloBoard(FAKE_CREDS.trello.boardid, [card]) + mockTrelloCard(FAKE_CREDS.trello.boardid, { + ...card, + idChecklists: [checklist.id], + }) + mockTrelloChecklist(checklist) + const message = createMessage({ + commands: [{ type: 'bot_command', text: '/next' }], + tags: [], + rest: '', + }) + const res = await getNextTrelloTasks(message, FAKE_CREDS) + // check expectation + expect(res.text).toEqual(expectedResult) + }) + + it('returns the first tasks of both cards of a board', async () => { + const testCases = [ + { cardName: 'Health', itemName: 'go to dentist' }, + { cardName: 'Home', itemName: 'buy new kitch lights' }, + ] + const expectedResult = testCases + .map((step) => `${step.cardName}: ${step.itemName}`) + .join('\n') + // run test + const cards = testCases.map((testCase, i) => ({ + ...trelloCardWithTag('someTag'), + id: `card${i}`, + name: testCase.cardName, + })) + mockTrelloBoard(FAKE_CREDS.trello.boardid, cards) + testCases.forEach((testCase, i) => { + mockTrelloCard(FAKE_CREDS.trello.boardid, { + ...cards[i], + idChecklists: [`checklist${i}`], + }) + mockTrelloChecklist({ + id: `checklist${i}`, + checkItems: [ + { pos: 1, state: 'incomplete', name: testCase.itemName }, + ] as TrelloChecklistItem[], + }) + }) + const message = createMessage({ + commands: [{ type: 'bot_command', text: '/next' }], + tags: [], + rest: '', + }) + const res = await getNextTrelloTasks(message, FAKE_CREDS) + // check expectation + expect(res.text).toEqual(expectedResult) + }) + + it(`skips cards that don't have a checklist`, async () => { + const testCases = [ + { cardName: 'Health' }, + { cardName: 'Home', itemName: 'buy new kitch lights' }, + ] + const expectedResult = `${testCases[1].cardName}: ${testCases[1].itemName}` + // run test + const cards = testCases.map((testCase, i) => ({ + ...trelloCardWithTag('someTag'), + id: `card${i}`, + name: testCase.cardName, + })) + mockTrelloBoard(FAKE_CREDS.trello.boardid, cards) + testCases.forEach((testCase, i) => { + mockTrelloCard(FAKE_CREDS.trello.boardid, { + ...cards[i], + idChecklists: testCase.itemName ? [`checklist${i}`] : [], + }) + if (testCase.itemName) { + mockTrelloChecklist({ + id: `checklist${i}`, + checkItems: [ + { pos: 1, state: 'incomplete', name: testCase.itemName }, + ] as TrelloChecklistItem[], + }) + } + }) + const message = createMessage({ + commands: [{ type: 'bot_command', text: '/next' }], + tags: [], + rest: '', + }) + const res = await getNextTrelloTasks(message, FAKE_CREDS) + // check expectation + expect(res.text).toEqual(expectedResult) + }) + + it(`skips cards that don't have a hashtag`, async () => { + const testCases = [ + { cardName: 'Health', itemName: 'go to dentist', tag: 'myTag' }, + { cardName: 'Home', itemName: 'buy new kitch lights' }, + ] + const expectedResult = `${testCases[0].cardName}: ${testCases[0].itemName}` + // run test + const cards = testCases.map((testCase, i) => ({ + ...(testCase.tag ? trelloCardWithTag(testCase.tag) : undefined), + id: `card${i}`, + name: testCase.cardName, + })) + mockTrelloBoard(FAKE_CREDS.trello.boardid, cards) + testCases.forEach((testCase, i) => { + mockTrelloCard(FAKE_CREDS.trello.boardid, { + ...cards[i], + idChecklists: [`checklist${i}`], + }) + mockTrelloChecklist({ + id: `checklist${i}`, + checkItems: [ + { pos: 1, state: 'incomplete', name: testCase.itemName }, + ] as TrelloChecklistItem[], + }) + }) + const message = createMessage({ + commands: [{ type: 'bot_command', text: '/next' }], + tags: [], + rest: '', + }) + const res = await getNextTrelloTasks(message, FAKE_CREDS) + // check expectation + expect(res.text).toEqual(expectedResult) + }) + }) +}) diff --git a/functions/src/use-cases/addToTrello.ts b/functions/src/use-cases/addToTrello.ts index 89319b5..8135f7a 100644 --- a/functions/src/use-cases/addToTrello.ts +++ b/functions/src/use-cases/addToTrello.ts @@ -1,4 +1,4 @@ -import { MessageHandlerOptions, BotResponse } from './../types' +import { CommandHandler, MessageHandlerOptions, BotResponse } from './../types' import { ParsedMessageEntities } from './../Telegram' import { Trello } from '../services/Trello' @@ -10,7 +10,7 @@ const CONFIG_KEYS = ['apikey', 'usertoken', 'boardid'] type CONFIG_KEYS_ENUM = typeof CONFIG_KEYS[number] -export type Options = { +export type TrelloOptions = { [CONFIG_NAMESPACE]: { [key in CONFIG_KEYS_ENUM]: string } } @@ -19,12 +19,14 @@ type TrelloCardWithTags = { tags: string[] } -const checkOptions = (options: MessageHandlerOptions): Options => { +// Populate TrelloOptions from MessageHandlerOptions. +// Throws if any required option is missing. +const checkOptions = (options: MessageHandlerOptions): TrelloOptions => { for (const key of Object.values(CONFIG_KEYS)) { if (!options?.[CONFIG_NAMESPACE]?.[key]) throw new Error(`missing ${CONFIG_NAMESPACE}.${key}`) } - return options as Options + return options as TrelloOptions } const cleanTag = (tag: string): string => @@ -33,7 +35,7 @@ const cleanTag = (tag: string): string => const renderTag = (tag: string): string => `#${cleanTag(tag)}` const extractTagsFromBinding = (card: TrelloCard): string[] => { - const tagList = (card.desc.match(RE_TRELLO_CARD_BINDING) || [])[1] + const tagList = ((card.desc || '').match(RE_TRELLO_CARD_BINDING) || [])[1] return tagList ? tagList.split(',').map(cleanTag) : [] } @@ -57,18 +59,15 @@ const getCardsBoundToTags = ( .map(({ card }) => card) } -type ActionFunction = ( - message: ParsedMessageEntities, - trello: Trello, - targetedCards: TrelloCard[], - options: Options -) => Promise - -const wrap = (func: ActionFunction) => async ( - message: ParsedMessageEntities, - messageHandlerOptions: MessageHandlerOptions -): Promise => { - const options = checkOptions(messageHandlerOptions) // may throw +async function fetchCardsWithTags( + handlerOpts: MessageHandlerOptions +): Promise<{ + trello: Trello + options: TrelloOptions + cardsWithTags: { card: TrelloCard; tags: string[] }[] + validTags: string +}> { + const options = checkOptions(handlerOpts) // may throw const trello = new Trello(options.trello.apikey, options.trello.usertoken) const cards = await trello.getCards(options.trello.boardid) const cardsWithTags = cards.map((card) => ({ @@ -77,19 +76,36 @@ const wrap = (func: ActionFunction) => async ( })) const validTags = listValidTags(cardsWithTags) if (!validTags.length) { - return { - text: `🤔 Please bind tags to your cards. How: https://github.com/adrienjoly/telegram-scribe-bot#2-bind-tags-to-trello-cards`, - } + throw new Error( + `🤔 Please bind tags to your cards. How: https://github.com/adrienjoly/telegram-scribe-bot#2-bind-tags-to-trello-cards` + ) } + return { trello, options, cardsWithTags, validTags } +} + +async function fetchTargetedCards( + message: ParsedMessageEntities, + handlerOpts: MessageHandlerOptions +): Promise<{ + trello: Trello + options: TrelloOptions + targetedCards: TrelloCard[] +}> { + const { + trello, + options, + cardsWithTags, + validTags, + } = await fetchCardsWithTags(handlerOpts) const noteTags = message.tags.map((tagEntity) => tagEntity.text) if (!noteTags.length) { - return { text: `🤔 Please specify at least one hashtag: ${validTags}` } + throw new Error(`🤔 Please specify at least one hashtag: ${validTags}`) } const targetedCards = getCardsBoundToTags(cardsWithTags, noteTags) if (!targetedCards.length) { - return { text: `🤔 No cards match. Please pick another tag: ${validTags}` } + throw new Error(`🤔 No cards match. Please pick another tag: ${validTags}`) } - return await func(message, trello, targetedCards, options) + return { trello, options, targetedCards } } const _addAsTrelloComment = async ( @@ -113,7 +129,7 @@ const _addAsTrelloTask = async ( message: ParsedMessageEntities, trello: Trello, targetedCards: TrelloCard[], - options: Options + options: TrelloOptions ): Promise => { const getUniqueCardChecklist = async ( checklistIds: string[] @@ -139,10 +155,9 @@ const _addAsTrelloTask = async ( ) const populatedCards = consideredCards.filter((card) => card.checklistName) if (!populatedCards.length) - return { - text: - '🤔 No checklists were found for these tags. Please retry without another tag.', - } + throw new Error( + '🤔 No checklists were found for these tags. Please retry without another tag.' + ) return { text: `✅ Added task at the top of these Trello cards' unique checklists: ${populatedCards .map((c) => c.cardName) @@ -150,5 +165,56 @@ const _addAsTrelloTask = async ( } } -export const addAsTrelloComment = wrap(_addAsTrelloComment) -export const addAsTrelloTask = wrap(_addAsTrelloTask) +async function _getNextTrelloTasks( + message: ParsedMessageEntities, + handlerOpts: MessageHandlerOptions +): Promise { + const { trello, cardsWithTags, options } = await fetchCardsWithTags( + handlerOpts + ) // may throw + const cards = cardsWithTags.filter(({ tags }) => tags.length > 0) + const boardId = options.trello.boardid + const nextSteps: { cardName: string; nextStep: string }[] = [] + await Promise.all( + cards.map(async ({ card }) => { + const checklistIds = await trello.getChecklistIds(boardId, card.id) + if (checklistIds.length > 0) { + const nextStep = await trello.getNextTodoItem(checklistIds[0]) + if (nextStep && nextStep.name) { + nextSteps.push({ cardName: card.name, nextStep: nextStep.name }) + } + } + }) + ) + return { + text: nextSteps + .map(({ cardName, nextStep }) => `${cardName}: ${nextStep}`) + .join('\n'), + } +} + +export const addAsTrelloComment: CommandHandler = (message, handlerOpts) => + fetchTargetedCards(message, handlerOpts) + .then(({ trello, targetedCards }) => + _addAsTrelloComment(message, trello, targetedCards) + ) + .catch((err) => ({ error: err, text: err.message })) + +export const addAsTrelloTask: CommandHandler = (message, handlerOpts) => + fetchTargetedCards(message, handlerOpts) + .then(({ trello, targetedCards, options }) => + _addAsTrelloTask(message, trello, targetedCards, options) + ) + .catch((err) => ({ error: err, text: err.message })) + +export const getNextTrelloTasks: CommandHandler = (message, handlerOpts) => + _getNextTrelloTasks(message, handlerOpts).catch((err) => ({ + error: err, + text: err.message, + })) + +export const getOrAddTrelloTasks: CommandHandler = (message, handlerOpts) => + (message.rest === '' ? getNextTrelloTasks : addAsTrelloTask)( + message, + handlerOpts + ) diff --git a/functions/test/trello.test.ts b/functions/test/trello.test.ts deleted file mode 100644 index 38975a8..0000000 --- a/functions/test/trello.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -/// - -import expect from 'expect' -import nock from 'nock' -import { - Options, - addAsTrelloComment, - addAsTrelloTask, -} from './../src/use-cases/addToTrello' -import { ParsedMessageEntities } from '../src/Telegram' - -const FAKE_CREDS: Options = { - trello: { - apikey: 'trelloApiKey', - boardid: 'trelloBoardId', - usertoken: 'trelloUserToken', - }, -} - -const trelloCardWithTag = (tag: string) => ({ - id: 'myCardId', - name: `Dummy card associated with ${tag}`, - desc: `telegram-scribe-bot:addCommentsFromTaggedNotes(${tag})`, -}) - -const createMessage = ({ ...overrides }): ParsedMessageEntities => ({ - date: new Date(), - commands: [], - tags: [], - rest: '', - ...overrides, -}) - -describe('trello use cases', () => { - before(() => { - nock.emitter.on('no match', ({ method, path }) => - console.warn(`⚠ no match for ${method} ${path}`) - ) - }) - - after(() => { - nock.cleanAll() - nock.enableNetConnect() - }) - - beforeEach(() => { - nock.cleanAll() - }) - - describe('(shared behaviors)', () => { - it('fails if trello credentials are not provided', async () => { - const message = createMessage({ rest: 'coucou' }) - const promise = addAsTrelloComment(message, {}) - expect(promise).rejects.toThrow('missing trello.apikey') - }) - - it('fails if trello credentials are empty', async () => { - const message = createMessage({ rest: 'coucou' }) - const options: Options = { - trello: { - apikey: '', - boardid: '', - usertoken: '', - }, - } - const promise = addAsTrelloComment(message, options) - expect(promise).rejects.toThrow('missing trello.apikey') - }) - - it('suggests existing tags if no tags were provided', async () => { - const tags = ['#card1tag', '#card2tag'] - const cards = tags.map((tag) => trelloCardWithTag(tag)) - const message = createMessage({ rest: 'coucou' }) - // simulate trello cards - nock('https://api.trello.com') - .get((uri) => - uri.includes(`/1/boards/${FAKE_CREDS.trello.boardid}/cards`) - ) - .reply(200, cards) - const res = await addAsTrelloComment(message, FAKE_CREDS) - expect(res.text).toMatch('Please specify at least one hashtag') - expect(res.text).toMatch(tags[0]) - expect(res.text).toMatch(tags[1]) - }) - - it('suggests existing tags if no card matches the tag', async () => { - const tagName = '#anActualTag'.toLowerCase() - const card = trelloCardWithTag(tagName) - const message = createMessage({ - rest: 'coucou', - commands: [{ type: 'bot_command', text: '/note' }], - tags: [{ type: 'hashtag', text: '#aRandomTag' }], - }) - nock('https://api.trello.com') - .get((uri) => uri.includes('/cards')) // actual path: /1/boards/trelloBoardId/cards?key=trelloApiKey&token=trelloUserToken - .reply(200, [card]) - const res = await addAsTrelloComment(message, FAKE_CREDS) - expect(res.text).toMatch('No cards match') - expect(res.text).toMatch('Please pick another tag') - expect(res.text).toMatch(tagName) - }) - - it('tolerates cards that are not associated with a tag', async () => { - const tagName = '#anActualTag' - const cards = [ - trelloCardWithTag(tagName), - { id: 'cardWithoutTag', name: `Card without tag`, desc: `` }, - ] - const message = createMessage({ - rest: 'coucou', - commands: [{ type: 'bot_command', text: '/note' }], - tags: [{ type: 'hashtag', text: '#aRandomTag' }], - }) - nock('https://api.trello.com') - .get((uri) => uri.includes('/cards')) // actual path: /1/boards/trelloBoardId/cards?key=trelloApiKey&token=trelloUserToken - .reply(200, cards) - const res = await addAsTrelloComment(message, FAKE_CREDS) - expect(res.text).toMatch('No cards match') - }) - - it('invites to bind tags to card, if none were found', async () => { - const cards = [ - { id: 'cardWithoutTag', name: `Card without tag`, desc: `` }, - ] - const message = createMessage({ - rest: 'coucou', - commands: [{ type: 'bot_command', text: '/note' }], - tags: [{ type: 'hashtag', text: '#aRandomTag' }], - }) - nock('https://api.trello.com') - .get((uri) => uri.includes('/cards')) // actual path: /1/boards/trelloBoardId/cards?key=trelloApiKey&token=trelloUserToken - .reply(200, cards) - const res = await addAsTrelloComment(message, FAKE_CREDS) - expect(res.text).toMatch('Please bind tags to your cards') - }) - }) - - describe('addAsTrelloComment', () => { - it('succeeds', async () => { - const tagName = '#myTag' - const message = createMessage({ - rest: 'coucou', - commands: [{ type: 'bot_command', text: '/note' }], - tags: [{ type: 'hashtag', text: tagName }], - }) - // simulate a trello card that is associated with the tag - nock('https://api.trello.com') - .get((uri) => uri.includes('/cards')) - .reply(200, [trelloCardWithTag(tagName)]) - // simulate the response of adding a comment to that card - nock('https://api.trello.com') - .post((uri) => uri.includes('/actions/comments')) - .reply(200, {}) - const res = await addAsTrelloComment(message, FAKE_CREDS) - expect(res.text).toMatch('Sent to Trello cards') - expect(res.text).toMatch(tagName) - }) - - it('succeeds if tag is specified without hash, in the card', async () => { - const tagName = 'myTag' - const message = createMessage({ - rest: 'coucou', - commands: [{ type: 'bot_command', text: '/note' }], - tags: [{ type: 'hashtag', text: `#${tagName}` }], - }) - // simulate a trello card that is associated with the tag - nock('https://api.trello.com') - .get((uri) => uri.includes('/cards')) - .reply(200, [trelloCardWithTag(tagName)]) - // simulate the response of adding a comment to that card - nock('https://api.trello.com') - .post((uri) => uri.includes('/actions/comments')) - .reply(200, {}) - const res = await addAsTrelloComment(message, FAKE_CREDS) - expect(res.text).toMatch('Sent to Trello cards') - expect(res.text).toMatch(tagName) - }) - }) - - describe('addAsTrelloTask', () => { - it('fails if matching card has no checklist', async () => { - const tagName = '#myTag' - const message = createMessage({ - rest: 'coucou', - commands: [{ type: 'bot_command', text: '/note' }], - tags: [{ type: 'hashtag', text: tagName }], - }) - const card = trelloCardWithTag(tagName) - // simulate a trello card that is associated with the tag - nock('https://api.trello.com') - .get((uri) => - uri.includes(`/1/boards/${FAKE_CREDS.trello.boardid}/cards`) - ) - .reply(200, [card]) - // simulate the absence of checklists in that trello card - nock('https://api.trello.com') - .get((uri) => - uri.includes( - `/1/boards/${FAKE_CREDS.trello.boardid}/cards/${card.id}` - ) - ) - .reply(200, { idChecklists: [] }) - const res = await addAsTrelloTask(message, FAKE_CREDS) - expect(res.text).toMatch('No checklists were found for these tags') - }) - - it('succeeds', async () => { - const tagName = '#myTag' - const checklistId = 'myChecklistId' - const message = createMessage({ - rest: 'coucou', - commands: [{ type: 'bot_command', text: '/next' }], - tags: [{ type: 'hashtag', text: tagName }], - }) - const card = trelloCardWithTag(tagName) - // simulate a trello card that is associated with the tag - nock('https://api.trello.com') - .get((uri) => - uri.includes(`/1/boards/${FAKE_CREDS.trello.boardid}/cards`) - ) - .reply(200, [card]) - // simulate a checklist of that trello card - nock('https://api.trello.com') - .get((uri) => - uri.includes( - `/1/boards/${FAKE_CREDS.trello.boardid}/cards/${card.id}` - ) - ) - .reply(200, { idChecklists: [checklistId] }) - // simulate a checklist of that trello card - nock('https://api.trello.com') - .get((uri) => uri.includes(`/1/checklists/${checklistId}`)) - .reply(200, { id: checklistId, name: 'My checklist' }) - // simulate the response of adding a task to that checklist - nock('https://api.trello.com') - .post((uri) => uri.includes(`/1/checklists/${checklistId}/checkitems`)) - .reply(200) - const res = await addAsTrelloTask(message, FAKE_CREDS) - expect(res.text).toMatch('Added task at the top of these Trello cards') - expect(res.text).toMatch(tagName) - expect(res.text).toMatch(card.name) - }) - }) -}) diff --git a/functions/tools/bot-cli.ts b/functions/tools/bot-cli.ts old mode 100644 new mode 100755 index b5fea59..39e8061 --- a/functions/tools/bot-cli.ts +++ b/functions/tools/bot-cli.ts @@ -1,3 +1,8 @@ +#!./node_modules/.bin/ts-node + +// To test the chatbot locally, run this command from the parent directory: +// $ tools/bot-cli.ts + import readline from 'readline' import { TelegramMessage, MessageEntity, TelegramUser } from './../src/Telegram' import { processMessage } from './../src/messageHandler' @@ -50,7 +55,7 @@ const getAnswer = (prompt: string): Promise => const main = async (): Promise => { console.warn( - `ℹ️ This bot client is connected to the accounts specified in .env` + `ℹ️ This bot client is connected to the accounts specified in .config.json` ) for (;;) { const rawMessage = await getAnswer('type a message for the chatbot:') diff --git a/functions/tools/github-pr.ts b/functions/tools/github-pr.ts old mode 100644 new mode 100755 index 6d7cf17..1f906bb --- a/functions/tools/github-pr.ts +++ b/functions/tools/github-pr.ts @@ -1,3 +1,8 @@ +#!./node_modules/.bin/ts-node + +// To test this API, run this command from the parent directory: +// $ tools/github-pr.ts + import { GitHub } from '../src/services/GitHub' // Load credentials from config file diff --git a/functions/tools/spotify-album.ts b/functions/tools/spotify-album.ts old mode 100644 new mode 100755 index e1f7269..af5a129 --- a/functions/tools/spotify-album.ts +++ b/functions/tools/spotify-album.ts @@ -1,3 +1,8 @@ +#!./node_modules/.bin/ts-node + +// To test this API, run this command from the parent directory: +// $ tools/spotify-albums.ts + import { Spotify, formatAlbum } from '../src/services/Spotify' import yaml from 'js-yaml' diff --git a/functions/tools/ticktick.ts b/functions/tools/ticktick.ts old mode 100644 new mode 100755 index 21224b0..951de44 --- a/functions/tools/ticktick.ts +++ b/functions/tools/ticktick.ts @@ -1,3 +1,8 @@ +#!./node_modules/.bin/ts-node + +// To test this API, run this command from the parent directory: +// $ tools/ticktick.ts + import { Ticktick } from '../src/services/Ticktick' // load credentials from config file diff --git a/functions/tools/trello-boards.ts b/functions/tools/trello-boards.ts old mode 100644 new mode 100755 index 06cdd0e..286201e --- a/functions/tools/trello-boards.ts +++ b/functions/tools/trello-boards.ts @@ -1,3 +1,8 @@ +#!./node_modules/.bin/ts-node + +// To test this API, run this command from the parent directory: +// $ tools/trello-boards.ts + import { Trello } from '../src/services/Trello' // load credentials from config file diff --git a/functions/tools/trello-checklist.ts b/functions/tools/trello-checklist-add.ts old mode 100644 new mode 100755 similarity index 91% rename from functions/tools/trello-checklist.ts rename to functions/tools/trello-checklist-add.ts index b780ea1..d31c273 --- a/functions/tools/trello-checklist.ts +++ b/functions/tools/trello-checklist-add.ts @@ -1,3 +1,8 @@ +#!./node_modules/.bin/ts-node + +// To test this API, run this command from the parent directory: +// $ tools/trello-checklist-add.ts + import readline from 'readline' import { Trello } from '../src/services/Trello' diff --git a/functions/tools/trello-checklist-get.ts b/functions/tools/trello-checklist-get.ts new file mode 100755 index 0000000..4bcac55 --- /dev/null +++ b/functions/tools/trello-checklist-get.ts @@ -0,0 +1,47 @@ +#!./node_modules/.bin/ts-node + +// To test this Trello API, run this command from the parent directory: +// $ tools/trello-checklist-get.ts # to get the list of boards +// $ tools/trello-checklist-get.ts + +import { Trello } from '../src/services/Trello' + +// load credentials from config file +const config = require(`${__dirname}/bot-config.js`) // eslint-disable-line @typescript-eslint/no-var-requires + +const USAGE = `tools/trello-checklist-get.ts ` + +const boardId = process.argv[2] + +const main = async (): Promise => { + const trello = new Trello( + config.trello.apikey as string, + config.trello.usertoken as string + ) + if (!boardId) { + console.error(`❌ missing board_id`) + console.error(`ℹ️ USAGE: ${USAGE}`) + const boards = await trello.getBoards() + boards.map(({ id, name }) => console.log(`${id} \t ${name}`)) + throw `Please run the command again with the board_id of your choice` + } + const cards = await trello.getCards(boardId) + for (const card of cards) { + const [checklistId] = await trello.getChecklistIds(boardId, card.id) + if (!checklistId) continue + const checklist = await trello.getChecklist(checklistId) + const nextStep = await trello.getNextTodoItem(checklistId) + return `first checklist for card ${card.name}: "${checklist.name}", with next step: ${nextStep.name}` + } + throw `no cards with checklists were found of board ${boardId}` +} + +main() + .then((res) => { + console.log(`✅ ${res}`) + process.exit(0) + }) + .catch((err) => { + console.error('❌', err) + process.exit(1) + }) diff --git a/functions/typings/trello/index.d.ts b/functions/typings/trello/index.d.ts index 7b4ea04..858381a 100644 --- a/functions/typings/trello/index.d.ts +++ b/functions/typings/trello/index.d.ts @@ -15,6 +15,14 @@ type TrelloBoard = { type TrelloChecklist = { id: string name: string + checkItems: TrelloChecklistItem[] +} + +// cf https://developer.atlassian.com/cloud/trello/rest/api-group-cards/#api-cards-id-checkitemstates-get +type TrelloChecklistItem = { + name: string + pos: number + state: 'incomplete' | 'complete' } declare class TrelloLib {