├── .env.example ├── .gitignore ├── README.md ├── __tests__ └── classes │ ├── Message.test.js │ ├── MessageProcessor.test.js │ ├── URLMessage.test.js │ └── WebPage.test.js ├── app.js ├── notion.example.sqlite ├── package-lock.json ├── package.json └── src ├── bot ├── bot-operation-messages.js ├── bot.js ├── handlers │ ├── callback-query-handlers.js │ ├── error-handlers.js │ ├── event-handler.js │ └── message-handlers.js ├── keyboards.js ├── messages-history.js ├── middleware.js ├── state.js └── utils.js ├── classes ├── Message.js ├── MessageProcessor.js ├── URLMessage.js ├── WebPage.js └── notion │ ├── NotionDatabase.js │ ├── NotionPage.js │ ├── NotionPageChildren.js │ ├── NotionPageProperties.js │ └── Website.js ├── databases ├── data-retriever.js ├── db.js └── store.js └── lib ├── Util.js └── logger.js /.env.example: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT_TOKEN= 2 | MY_USER_ID= 3 | NOTION_TOKEN= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | notion.sqlite -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧪 Notion-Potion 2 | 3 | ## Effortlessly brew new Notion pages with Notion-Potion Telegram Bot! 4 | 5 | Notion-Potion is a Telegram bot that lets you quickly and easily save new notes, tasks, events etc... to your [Notion](https://notion.so) workspace. 6 | 7 |

8 | 9 | 10 | 11 |

12 | 13 | ## 🪄 Inspiration 14 | 15 | The idea behind this bot is heavily inspired by [David Allen's book Getting Things Done](https://www.amazon.ca/Getting-Things-Done-Stress-Free-Productivity/dp/0143126563/). And, is the upgrade to the method used in this previous blog post: [Implementing a GTD inbox in Notion](https://blog.shorouk.dev/2021/10/gtd-inbox-in-notion/) 16 | 17 | ## 🔮How it Works 18 | 19 | https://github.com/ShoroukAziz/notion-potion/assets/27028706/8d2933bd-9206-4f2c-9e3f-438ec0d29ace 20 | 21 | 22 | 23 | ### 💬 Sending text messages 24 | 25 | - You send a message to your bot. It get's automatically saved to your Notion Inbox database 26 | 27 | - You then can then do any of the following 28 | - Rename the page 29 | - Move the page to another database ex: tasks, notes, etc... 30 | - Link the page to a specific project 31 | - link the page to a specific Topic 32 | - Delete the page 33 | 34 | - Or, you can tell the bot directly where to save the page 35 | - for example to save a page to your notes you append or prepend your message with `@note ` 36 | 37 | ### 🔗 Sending URLs 38 | 39 | - Links that matches a specific website you have set up before will get saved directly to the specified database 40 | - Ex: a URL of Youtube video gets added to the Bookmarks database while a URL of an Amazon product gets saved in the Shopping database 41 | - Text you send with the URL gets saved inside the page as a text block. 42 | 43 | --- 44 | 45 | ## 📃 Documentation 46 | 47 | 🚧 Coming soon 48 | 49 | ## ⚠️ Prerequisites 50 | 51 | - You have a telegram bot 52 | - Use [BotFather](https://telegram.me/BotFather) to create one and obtain your token 53 | - Yon know your telegram user id 54 | - use [jsondumpbot](https://telegram.me/jsondumpbot) to get it. 55 | - You have a [Notion API Token](https://www.notion.so/my-integrations) 56 | - You have your Notion workspace setup [🚧 tutorial coming soon] 57 | 58 | ## 🚀 Getting Started 59 | 60 | - Fork this repository and clone it to your local machine. 61 | - Copy .env.example and rename it .env and fill in your tokens and telegram user id 62 | ```YAML 63 | TELEGRAM_BOT_TOKEN=Your telegram bot token 64 | MY_USER_ID=your telegram user id 65 | NOTION_TOKEN=your Notion token 66 | ``` 67 | - copy notion.example.sqlite and rename it to notion.sqlite 68 | - Replace the example databases with your databases [🚧 tutorial coming soon] 69 | - Install all the dependencies ` npm install` 70 | - Run the bot 🥳 ` npm run` 71 | 72 | ## 🧱 File Structure 73 | 74 | ```sh 75 | ├── docs # screenshots for readme 76 | ├── src 77 | │ ├── bot # All the bot code like the event handlers, keyboards, state, etc... 78 | │ ├── classes # All the Classes used by the bot 79 | │ ├── databases # Connection to the database and all the data retrieval 80 | │ ├── lib # Helper classes like Logger and Util 81 | ├─── __test__ # Testing code. 82 | ├─── .env.example # example env file. 83 | ├─── notion.sqlite.example # example database file. 84 | ├─── app.js # Entry point for the bot. 85 | ├─── package-lock.json 86 | ├─── package.json 87 | └─── README.md 88 | ``` 89 | 90 | ## 📦 Dependencies 91 | 92 | - 🔳 [@notionhq/client](<[express.js](https://developers.notion.com/docs/getting-started)>) 93 | - 🤖 [node-telegram-bot-api](https://github.com/yagop/node-telegram-bot-api) 94 | - 📰 [axios](https://axios-http.com) 95 | - 🎨 [chalk](https://github.com/chalk/chalk#readme) 96 | - 🔐 [dotenv](https://github.com/motdotla/dotenv#readme) 97 | - 📝 [node-html-parser](https://github.com/taoqf/node-fast-html-parser) 98 | - 😈 [nodemon](https://nodemon.io) 99 | - 📑 [sqlite3](https://github.com/TryGhost/node-sqlite3) 100 | 101 | ### 🧰 Development Dependencies 102 | 103 | - 👢 [jest](https://jestjs.io/) 104 | -------------------------------------------------------------------------------- /__tests__/classes/Message.test.js: -------------------------------------------------------------------------------- 1 | const Message = require('../../src/classes/Message'); 2 | 3 | const databases = { 4 | stuff: {}, 5 | notes: { tag: '@note' }, 6 | }; 7 | const processedMessage = { 8 | text: 'This is some text', 9 | database: databases.stuff, 10 | }; 11 | 12 | describe('MessageProcessor', () => { 13 | it('Should get the text and database oyt of the processed message', () => { 14 | const message = new Message(processedMessage); 15 | expect(message.text).toEqual('This is some text'); 16 | expect(message.database).toEqual(databases.stuff); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /__tests__/classes/MessageProcessor.test.js: -------------------------------------------------------------------------------- 1 | const MessageProcessor = require('../../src/classes/MessageProcessor'); 2 | 3 | const databases = { 4 | stuff: {}, 5 | notes: { tag: '@note' }, 6 | }; 7 | 8 | describe('MessageProcessor', () => { 9 | it('Should processes message with database tag at the beginning of message', () => { 10 | const incomingMessage = '@note This is some other text'; 11 | const processedMessage = new MessageProcessor(incomingMessage, databases); 12 | 13 | expect(processedMessage.text).toEqual('This is some other text'); 14 | expect(processedMessage.database).toEqual(databases.notes); 15 | expect(processedMessage.url).toBeNull(); 16 | }); 17 | 18 | it('Should processes message with database tag at the end of message', () => { 19 | const incomingMessage = 'This is some other text @note'; 20 | const processedMessage = new MessageProcessor(incomingMessage, databases); 21 | 22 | expect(processedMessage.text).toEqual('This is some other text'); 23 | expect(processedMessage.database).toEqual(databases.notes); 24 | expect(processedMessage.url).toBeNull(); 25 | }); 26 | 27 | it('Should processes message with URL', () => { 28 | const incomingMessage = 'http://example.com This is some other text'; 29 | const processedMessage = new MessageProcessor(incomingMessage, databases); 30 | 31 | expect(processedMessage.text).toEqual('This is some other text'); 32 | expect(processedMessage.database).toEqual(databases.stuff); 33 | expect(processedMessage.url).toEqual('http://example.com'); 34 | }); 35 | 36 | it('Should processes message with database and URL', () => { 37 | const incomingMessage = '@note This is some text http://example.com'; 38 | const processedMessage = new MessageProcessor(incomingMessage, databases); 39 | 40 | expect(processedMessage.text).toEqual('This is some text'); 41 | expect(processedMessage.database).toEqual(databases.notes); 42 | expect(processedMessage.url).toEqual('http://example.com'); 43 | }); 44 | 45 | it('Should processes message with database and URL in any order', () => { 46 | const incomingMessage = '@note http://example.com This is some text'; 47 | const processedMessage = new MessageProcessor(incomingMessage, databases); 48 | 49 | expect(processedMessage.text).toEqual('This is some text'); 50 | expect(processedMessage.database).toEqual(databases.notes); 51 | expect(processedMessage.url).toEqual('http://example.com'); 52 | }); 53 | 54 | it('processes message with no database and no URL', () => { 55 | const incomingMessage = 'This is some more text'; 56 | const processedMessage = new MessageProcessor(incomingMessage, databases); 57 | 58 | expect(processedMessage.text).toEqual('This is some more text'); 59 | expect(processedMessage.database).toEqual(databases.stuff); 60 | expect(processedMessage.url).toBeNull(); 61 | }); 62 | 63 | it('processes message with URL only', () => { 64 | const incomingMessage = 'http://example.com'; 65 | const processedMessage = new MessageProcessor(incomingMessage, databases); 66 | 67 | expect(processedMessage.text).toEqual(''); 68 | expect(processedMessage.database).toEqual(databases.stuff); 69 | expect(processedMessage.url).toEqual('http://example.com'); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /__tests__/classes/URLMessage.test.js: -------------------------------------------------------------------------------- 1 | const URLMessage = require('../../src/classes/URLMessage'); 2 | const axios = require('axios'); 3 | const HTMLParser = require('node-html-parser'); 4 | 5 | // Example databases 6 | const databases = { 7 | stuff: { name: 'stuff' }, 8 | notes: { name: 'notes', tag: '@note' }, 9 | bookmarks: {}, 10 | }; 11 | 12 | // Example websites 13 | const websites = { 14 | youtube: { 15 | parent: databases.bookmarks, 16 | name: 'youtube', 17 | downloadHTMLRules: { 18 | urlPattern: '.*youtube.com.*', 19 | titlePattern: ' - YouTube', 20 | }, 21 | }, 22 | }; 23 | 24 | describe("URLMessage with a Url that doesn't match any website", () => { 25 | let processedMessage, urlMessage; 26 | beforeEach(() => { 27 | processedMessage = { 28 | text: 'some text', 29 | database: databases.stuff, 30 | url: 'https://www.example.com/', 31 | }; 32 | urlMessage = new URLMessage(processedMessage, websites, axios, HTMLParser); 33 | }); 34 | it('Should have the url, text and db from the processed message', async () => { 35 | await urlMessage.run(); 36 | expect(urlMessage.url).toEqual(processedMessage.url); 37 | expect(urlMessage.text).toEqual(processedMessage.text); 38 | expect(urlMessage.database).toEqual(processedMessage.database); 39 | }); 40 | 41 | it('Should have the url title', async () => { 42 | await urlMessage.run(); 43 | expect(urlMessage.title).toEqual('Example Domain'); 44 | }); 45 | 46 | it('Should not have a website', async () => { 47 | await urlMessage.run(); 48 | expect(urlMessage.website).toBeNull(); 49 | }); 50 | }); 51 | 52 | describe('URLMessage with a Url that matches a website', () => { 53 | beforeEach(() => { 54 | processedMessage = { 55 | text: 'This is some text', 56 | database: databases.stuff, 57 | url: 'https://www.youtube.com/watch?v=XEt09iK8IXs', 58 | }; 59 | urlMessage = new URLMessage(processedMessage, websites, axios, HTMLParser); 60 | }); 61 | 62 | it('Should have the url and text from the processed message', async () => { 63 | await urlMessage.run(); 64 | expect(urlMessage.url).toEqual(processedMessage.url); 65 | expect(urlMessage.text).toEqual(processedMessage.text); 66 | }); 67 | 68 | it('Should recognize the website', async () => { 69 | await urlMessage.run(); 70 | expect(urlMessage.website).toEqual(websites.youtube); 71 | }); 72 | 73 | it('Should save the title of the url', async () => { 74 | await urlMessage.run(); 75 | expect(urlMessage.title).toEqual('Coding Interview with Dan Abramov'); 76 | }); 77 | 78 | it('Should have the database of the website', async () => { 79 | await urlMessage.run(); 80 | expect(urlMessage.database).toEqual(databases.bookmarks); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /__tests__/classes/WebPage.test.js: -------------------------------------------------------------------------------- 1 | const Webpage = require('../../src/classes/WebPage'); 2 | 3 | describe('Webpage', () => { 4 | describe('_downloadPage', () => { 5 | const url = 'http://example.com'; 6 | const mockResponse = { 7 | data: 'Example

Hello, world!

', 8 | }; 9 | const httpClient = { get: jest.fn(() => Promise.resolve(mockResponse)) }; 10 | 11 | it('should return the HTML content of the webpage', () => { 12 | const webpage = new Webpage(url, httpClient, null, null); 13 | return webpage._downloadPage(url, httpClient).then((htmlContent) => { 14 | expect(htmlContent).toEqual(mockResponse.data); 15 | }); 16 | }); 17 | 18 | it('should set the HTML content as a class property', () => { 19 | const webpage = new Webpage(url, httpClient, null, null); 20 | return webpage._downloadPage(url, httpClient).then(() => { 21 | expect(webpage.HTMLContent).toEqual(mockResponse.data); 22 | }); 23 | }); 24 | 25 | //TODO: review error handling testing 26 | it('should throw an error if the HTTP request fails', () => { 27 | const error = new Error('HTTP request failed'); 28 | httpClient.get.mockImplementationOnce(() => Promise.reject(error)); 29 | const webpage = new Webpage(url, httpClient, null, null); 30 | return webpage._downloadPage(url, httpClient).catch((err) => { 31 | expect(err).toEqual(error); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('_parseHTML', () => { 37 | test('Should return the parsed HTML content', () => { 38 | const HTMLContent = 39 | 'My title

Some content

'; 40 | const parserMock = { 41 | parse: jest 42 | .fn() 43 | .mockReturnValue({ title: 'My title', content: 'Some content' }), 44 | }; 45 | const webpage = new Webpage('http://example.com', null, parserMock, null); 46 | 47 | const parsedHTML = webpage._parseHTML(HTMLContent, parserMock); 48 | 49 | expect(parsedHTML).toEqual({ 50 | title: 'My title', 51 | content: 'Some content', 52 | }); 53 | expect(parserMock.parse).toHaveBeenCalledWith(HTMLContent); 54 | }); 55 | }); 56 | 57 | describe('_setTitle', () => { 58 | let webpage; 59 | 60 | beforeEach(() => { 61 | const html = ` 62 | 63 | 64 | Test Title --removeMe 65 | 66 | 67 |

Test Header

68 | 69 | 70 | `; 71 | const htmlParser = { 72 | parse: jest.fn(() => ({ 73 | querySelector: jest.fn((selector) => { 74 | if (selector === 'title') { 75 | return { text: 'Test Title' }; 76 | } else if (selector === 'h1') { 77 | return { text: 'Test Header' }; 78 | } 79 | }), 80 | })), 81 | }; 82 | webpage = new Webpage('http://example.com', {}, htmlParser, {}); 83 | webpage.html = htmlParser.parse(html); 84 | }); 85 | 86 | it('should set title based on parsing rules', () => { 87 | webpage.parsingRules = { titleHtmlTag: 'h1', titlePattern: 'Test' }; 88 | webpage._setTitle(); 89 | expect(webpage.title).toBe('Header'); 90 | }); 91 | 92 | it('should set title to title tag content if parsing rules not provided', () => { 93 | webpage._setTitle(); 94 | expect(webpage.title).toBe('Test Title'); 95 | }); 96 | 97 | it('should set title to header tag content if title tag not found', () => { 98 | webpage.html.querySelector = jest.fn((selector) => { 99 | if (selector === 'title') { 100 | return null; 101 | } else if (selector === 'h1') { 102 | return { text: 'Test Header' }; 103 | } 104 | }); 105 | webpage._setTitle(); 106 | expect(webpage.title).toBe('Test Header'); 107 | }); 108 | 109 | it('should set title to fallback value if neither title nor header tag found', () => { 110 | webpage.html.querySelector = jest.fn(() => null); 111 | webpage._setTitle(); 112 | expect(webpage.title).toBe('New URL'); 113 | }); 114 | 115 | it('should replace title pattern with empty string if pattern provided', () => { 116 | webpage.parsingRules = { 117 | titleHtmlTag: 'title', 118 | titlePattern: ' --removeMe', 119 | }; 120 | webpage._setTitle(); 121 | expect(webpage.title).toBe('Test Title'); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Entry point for the bot. 3 | * Retrieves data, then registers event and error handlers for the bot. 4 | */ 5 | const { logSuccess } = require('./src/lib/logger'); 6 | const { 7 | registerBotEventHandlers, 8 | } = require('./src/bot/handlers/event-handler'); 9 | const { 10 | registerBotErrorHandlers, 11 | } = require('./src/bot/handlers/error-handlers'); 12 | const retrieveData = require('./src/databases/data-retriever'); 13 | 14 | const init = async function () { 15 | await retrieveData(); 16 | logSuccess('🚀 Starting bot (polling) ...'); 17 | registerBotEventHandlers(); 18 | registerBotErrorHandlers(); 19 | }; 20 | 21 | init(); 22 | -------------------------------------------------------------------------------- /notion.example.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShoroukAziz/notion-potion/f612deca13c7cf8d4ed50c605ccdf2cc9d107d5f/notion.example.sqlite -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-potion", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "bot.js", 6 | "scripts": { 7 | "start": "nodemon app.js", 8 | "test": "jest" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/ShoroukAziz/notion-potion.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/ShoroukAziz/notion-potion/issues" 19 | }, 20 | "homepage": "https://github.com/ShoroukAziz/notion-potion#readme", 21 | "dependencies": { 22 | "@notionhq/client": "^2.2.3", 23 | "axios": "^1.3.4", 24 | "chalk": "^4.1.2", 25 | "dotenv": "^16.0.3", 26 | "node-html-parser": "^6.1.5", 27 | "node-telegram-bot-api": "^0.61.0", 28 | "nodemon": "^2.0.21", 29 | "sqlite3": "^5.1.6" 30 | }, 31 | "devDependencies": { 32 | "jest": "^29.5.0" 33 | }, 34 | "jest": { 35 | "watchPathIgnorePatterns": [ 36 | "/node_modules/", 37 | "/.git/" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/bot/bot-operation-messages.js: -------------------------------------------------------------------------------- 1 | /* This module contains a set of messages and log messages used by the bot to interact with the user and the console. 2 | * Each action key defines a set of messages used for a specific action 3 | * such as saving, renaming, adding details, moving, adding topics, adding projects, outboxing, and deleting a page. 4 | * Message types: 5 | * logInputMessage: Message logged to console when the user click on the operation button. 6 | * onClickMessage: Message sent to user when the user click on the operation button 7 | * onCancelMessage: Message sent to user when the user cancels the operation by typing /cancel. 8 | * successMessage: Message sent to user when the operation is successfully done 9 | * logSuccessMessage :Message logged to console when the operation is successfully done 10 | */ 11 | module.exports = { 12 | save: { 13 | logInputMessage: 'Received a new message: %s', 14 | successMessage: 'created page [%s](%s)', 15 | logSuccessMessage: 'Created a Notion page "%s" at: %s.', 16 | }, 17 | rename: { 18 | logInputMessage: 'Requested to rename the page to %s', 19 | onClickMessage: 'Enter a new title or /cancel ', 20 | onCancelMessage: 'Okay, Will keep that title.', 21 | successMessage: 'The page is renamed to [%s](%s)', 22 | logSuccessMessage: 'Renamed a Notion page to %s at %s', 23 | }, 24 | details: { 25 | logInputMessage: 'Requested to add details: %s', 26 | onClickMessage: 'Enter the details you want to add to the page or /cancel', 27 | onCancelMessage: 'Anything else?', 28 | successMessage: 'added text block to [%s](%s)', 29 | logSuccessMessage: 'Added text block to a Notion page %s at: %s', 30 | }, 31 | move: { 32 | logInputMessage: '', 33 | onClickMessage: `Where to?`, 34 | successMessage: '[%s](%s) moved', 35 | logSuccessMessage: 'Moved the page', 36 | }, 37 | addTopic: { 38 | logInputMessage: '', 39 | onClickMessage: `Which Topic?`, 40 | successMessage: `Added topic`, 41 | logSuccessMessage: 'Added topic', 42 | }, 43 | addProject: { 44 | logInputMessage: '', 45 | onClickMessage: `Which Project?`, 46 | successMessage: `Added project`, 47 | logSuccessMessage: 'Added project', 48 | }, 49 | outbox: { 50 | logInputMessage: 'Requested to outbox a page.', 51 | successMessage: 'Page out of inbox!', 52 | logSuccessMessage: 'Page out of inbox', 53 | }, 54 | delete: { 55 | logInputMessage: 'Requested to delete a page.', 56 | successMessage: 'Page Deleted!', 57 | logSuccessMessage: 'Page Deleted!', 58 | }, 59 | done: { 60 | logInputMessage: 'Ended The conversation.', 61 | }, 62 | back: { successMessage: 'Anything else?' }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/bot/bot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module exports a TelegramBot instance initialized with the 3 | * TELEGRAM_BOT_TOKEN environment variable value and configured to use 4 | * long polling to receive updates from Telegram servers. 5 | */ 6 | 7 | require('dotenv').config(); 8 | const TelegramBot = require('node-telegram-bot-api'); 9 | 10 | const token = process.env.TELEGRAM_BOT_TOKEN; 11 | const bot = new TelegramBot(token, { polling: true }); 12 | module.exports = bot; 13 | -------------------------------------------------------------------------------- /src/bot/handlers/callback-query-handlers.js: -------------------------------------------------------------------------------- 1 | const data = require('../../databases/store'); 2 | 3 | const { logInput } = require('../../lib/logger'); 4 | const bot = require('../bot'); 5 | const { getKeyboardFromList } = require('../keyboards'); 6 | const messagesHistory = require('../messages-history'); 7 | let { STATE } = require('../state'); 8 | const operations = require('../bot-operation-messages'); 9 | const { handleError } = require('./error-handlers'); 10 | const { getLastMessage, handleOperationSuccess } = require('../utils'); 11 | 12 | /***************************************************************************************************************** 13 | * * 14 | * Operations handlers * 15 | * These Handel clicking any of the 8 basic operations buttons like : rename, move, delete ..etc * * 16 | * * 17 | *****************************************************************************************************************/ 18 | const handleOutbox = async function (chatId) { 19 | STATE.current = STATE.waiting; 20 | 21 | logInput(operations.outbox.logInputMessage); 22 | try { 23 | await getLastMessage().outbox(); 24 | handleOperationSuccess(chatId, operations.outbox); 25 | } catch (error) { 26 | handleError(error, chatId); 27 | } 28 | }; 29 | 30 | const handleDelete = async function (chatId) { 31 | STATE.current = STATE.waiting; 32 | const operation = operations.delete; 33 | STATE.current = STATE.waiting; 34 | logInput(operation.logInputMessage); 35 | try { 36 | await getLastMessage().delete(); 37 | handleOperationSuccess(chatId, operation, false); 38 | messagesHistory.pop(); 39 | } catch (error) { 40 | handleError(error, chatId); 41 | } 42 | }; 43 | 44 | const handleDone = function () { 45 | STATE.current = STATE.waiting; 46 | logInput(operations.done.logInputMessage); 47 | }; 48 | 49 | const handleBack = function (chatId) { 50 | handleOperationSuccess(chatId, operations.back); 51 | }; 52 | 53 | // The following handlers require changing the state 54 | // because the user will be sending a follow up message 55 | 56 | const handleRename = function (chatId) { 57 | STATE.current = STATE.rename; 58 | bot.sendMessage(chatId, operations.rename.onClickMessage); 59 | }; 60 | 61 | const handleDetails = function (chatId) { 62 | STATE.current = STATE.details; 63 | bot.sendMessage(chatId, operations.details.onClickMessage); 64 | }; 65 | 66 | ///////////// 67 | 68 | const handleMove = async function (chatId) { 69 | const { databases } = data; 70 | const list = Object.values(databases).map((notionDb) => { 71 | return { 72 | name: `${notionDb.icon} ${notionDb.name}`, 73 | callbackData: `database#${notionDb.name}`, 74 | }; 75 | }); 76 | 77 | bot.sendMessage(chatId, operations.move.onClickMessage, { 78 | parse_mode: 'Markdown', 79 | reply_markup: getKeyboardFromList(list, 4), 80 | }); 81 | }; 82 | 83 | const handleAddTopic = async function (chatId) { 84 | const { databases } = data; 85 | const topicsList = await databases.Topics.query(); 86 | const list = Object.values(topicsList.results).map((topic) => { 87 | return { 88 | name: topic.properties.Name.title[0].plain_text, 89 | callbackData: `topic#${topic.id}`, 90 | }; 91 | }); 92 | bot.sendMessage(chatId, operations.addTopic.onClickMessage, { 93 | parse_mode: 'Markdown', 94 | reply_markup: getKeyboardFromList(list, 4), 95 | }); 96 | }; 97 | const handleAddProject = async function (chatId) { 98 | const { databases } = data; 99 | const topicsList = await databases.Projects.query(); 100 | const list = Object.values(topicsList.results).map((Project) => { 101 | return { 102 | name: Project.properties.Name.title[0].plain_text, 103 | callbackData: `project#${Project.id}`, 104 | }; 105 | }); 106 | bot.sendMessage(chatId, operations.addProject.onClickMessage, { 107 | parse_mode: 'Markdown', 108 | reply_markup: getKeyboardFromList(list, 1), 109 | }); 110 | }; 111 | 112 | /***************************************************************************************************************** 113 | * * 114 | * Data selectors Handlers: * 115 | * These handles selecting a button after clicking an operation button to select some data like a new database * 116 | * a topic or a project * 117 | * * 118 | *****************************************************************************************************************/ 119 | 120 | const handleSelectedNewDatabase = async function (chatId, newDatabaseName) { 121 | const { databases } = data; 122 | 123 | try { 124 | await getLastMessage().move(databases[newDatabaseName]); 125 | handleOperationSuccess(chatId, operations.move); 126 | } catch (error) { 127 | handleError(error, chatId); 128 | } 129 | }; 130 | 131 | const handleSelectedTopic = async function (chatId, topicId) { 132 | try { 133 | await getLastMessage().addRelation('Topic', topicId); 134 | handleOperationSuccess(chatId, operations.addTopic); 135 | } catch (error) { 136 | handleError(error, chatId); 137 | } 138 | }; 139 | const handleSelectedProject = async function (chatId, projectId) { 140 | try { 141 | await getLastMessage().addRelation('Project', projectId); 142 | handleOperationSuccess(chatId, operations.addProject); 143 | } catch (error) { 144 | handleError(error, chatId); 145 | } 146 | }; 147 | 148 | module.exports = { 149 | handleRename, 150 | handleMove, 151 | handleDetails, 152 | handleAddTopic, 153 | handleAddProject, 154 | handleOutbox, 155 | handleDelete, 156 | handleDone, 157 | handleSelectedNewDatabase, 158 | handleSelectedTopic, 159 | handleSelectedProject, 160 | handleBack, 161 | }; 162 | -------------------------------------------------------------------------------- /src/bot/handlers/error-handlers.js: -------------------------------------------------------------------------------- 1 | /* This module defines event handlers for handling errors that can occur 2 | * while the Telegram bot is running. 3 | */ 4 | const { logError } = require('../../lib/logger'); 5 | const bot = require('../bot'); 6 | 7 | const handleError = function (error, chatId) { 8 | logError(error); 9 | bot.sendMessage(chatId, error.toString()); 10 | }; 11 | 12 | const registerBotErrorHandlers = function () { 13 | // Handle polling errors 14 | bot.on('polling_error', (error) => { 15 | console.log(error); 16 | }); // => 'EFATAL' 17 | 18 | //Handle webhook errors 19 | bot.on('webhook_error', (error) => { 20 | console.log(error); 21 | }); // => 'EPARSE' 22 | }; 23 | module.exports = { registerBotErrorHandlers, handleError }; 24 | -------------------------------------------------------------------------------- /src/bot/handlers/event-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file exports a function that registers event handlers for the bot. 3 | * The handlers respond to messages, and more to come 4 | * 5 | */ 6 | 7 | const bot = require('../bot'); 8 | const { 9 | handleNewMessage, 10 | handleRenameMessage, 11 | handleDetailsMessage, 12 | } = require('./message-handlers'); 13 | 14 | const { 15 | handleRename, 16 | handleMove, 17 | handleDetails, 18 | handleAddTopic, 19 | handleAddProject, 20 | handleOutbox, 21 | handleDelete, 22 | handleDone, 23 | handleSelectedNewDatabase, 24 | handleSelectedTopic, 25 | handleSelectedProject, 26 | handleBack, 27 | } = require('./callback-query-handlers'); 28 | const { checkAuthorization } = require('../middleware'); 29 | const { STATE } = require('../state'); 30 | 31 | const clearUpOldKeyboard = async function (incomingTextMessage) { 32 | bot.editMessageReplyMarkup(null, { 33 | chat_id: incomingTextMessage.chat.id, 34 | message_id: STATE.lastKeyboardMessage.message_id, 35 | }); 36 | 37 | STATE.current = STATE.waiting; 38 | handlers.messageHandlers.waiting(incomingTextMessage); 39 | }; 40 | 41 | const handlers = { 42 | callbackQueryHandlers: { 43 | operation: { 44 | //Multiple Action buttons: Clicking these button require either clicking other buttons or typing more text to finish the action 45 | rename: handleRename, 46 | move: handleMove, 47 | details: handleDetails, 48 | addTopic: handleAddTopic, 49 | addProject: handleAddProject, 50 | //Single Action buttons: These actions are done once the button is clicked 51 | outbox: handleOutbox, 52 | delete: handleDelete, 53 | done: handleDone, 54 | back: handleBack, 55 | }, 56 | dataSelection: { 57 | database: handleSelectedNewDatabase, 58 | topic: handleSelectedTopic, 59 | project: handleSelectedProject, 60 | }, 61 | }, 62 | messageHandlers: { 63 | waiting: handleNewMessage, 64 | rename: handleRenameMessage, 65 | details: handleDetailsMessage, 66 | selecting: clearUpOldKeyboard, 67 | }, 68 | }; 69 | 70 | const handleCallBackQuery = async function (callbackQuery) { 71 | const action = callbackQuery.data.split('#'); 72 | const actionId = action[0]; 73 | const actionData = action[1]; 74 | const msg = callbackQuery.message; 75 | const chatId = msg.chat.id; 76 | const messageId = msg.message_id; 77 | 78 | //Clear the previous keyboard 79 | bot.deleteMessage(chatId, messageId); 80 | if (actionId === 'operation') { 81 | handlers.callbackQueryHandlers.operation[actionData](chatId); 82 | } else { 83 | handlers.callbackQueryHandlers.dataSelection[actionId](chatId, actionData); 84 | } 85 | 86 | bot.answerCallbackQuery(callbackQuery.id).catch((err) => { 87 | logError(`Failed to answer callback query: ${err}`); 88 | }); 89 | }; 90 | 91 | const handleMessage = async function (incomingTextMessage) { 92 | handlers.messageHandlers[STATE.current](incomingTextMessage); 93 | }; 94 | 95 | const authorizedHandelCallBackQuery = checkAuthorization(handleCallBackQuery); 96 | const authorizedHandleMessage = checkAuthorization(handleMessage); 97 | 98 | const registerBotEventHandlers = function () { 99 | //TODO 100 | bot.onText(/\/shopping/, (incomingTextMessage) => { 101 | // bot.sendMessage(msg.chat.id, 'Okay, Will keep that title'); 102 | }); 103 | 104 | // Respond to text messages 105 | bot.on('message', (incomingTextMessage) => { 106 | authorizedHandleMessage(incomingTextMessage); 107 | }); 108 | bot.on('callback_query', (query) => { 109 | authorizedHandelCallBackQuery(query); 110 | }); 111 | }; 112 | module.exports = { registerBotEventHandlers }; 113 | -------------------------------------------------------------------------------- /src/bot/handlers/message-handlers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file exports a function that handles incoming messages to the Telegram bot. 3 | * The function uses middleware to check the authorization 4 | * of the sender and process the message to a Notion page. 5 | */ 6 | const { logInput } = require('../../lib/logger'); 7 | const bot = require('../bot'); 8 | const MessageProcessor = require('../../classes/MessageProcessor'); 9 | const Message = require('../../classes/Message'); 10 | const URLMessage = require('../../classes/URLMessage'); 11 | const NotionPage = require('../../classes/notion/NotionPage'); 12 | const messagesHistory = require('../messages-history'); 13 | let { STATE } = require('../state'); 14 | const operations = require('../bot-operation-messages'); 15 | const { handleError } = require('./error-handlers'); 16 | const { 17 | handleCancel, 18 | getLastMessage, 19 | handleOperationSuccess, 20 | } = require('../utils'); 21 | /** 22 | 23 | Handles incoming messages by processing them to Notion and sending the result back to the user. 24 | @param {Object} incomingTextMessage The incoming message from the chat to the bot. 25 | */ 26 | 27 | const handleNewMessage = async function (incomingTextMessage) { 28 | const operation = operations.save; 29 | 30 | logInput(operation.logInputMessage, incomingTextMessage.text); 31 | 32 | try { 33 | const processedMessage = new MessageProcessor(incomingTextMessage.text); 34 | const message = processedMessage.url 35 | ? new URLMessage(processedMessage) 36 | : new Message(processedMessage); 37 | 38 | await message.process(); 39 | const notionPage = new NotionPage(message); 40 | const notionResponse = await notionPage.createNewPage(); 41 | notionPage.id = notionResponse.id; 42 | notionPage.notionURL = notionResponse.url; 43 | messagesHistory.push(notionPage); 44 | handleOperationSuccess(incomingTextMessage.chat.id, operations.save); 45 | } catch (error) { 46 | handleError(error, incomingTextMessage.chat.id); 47 | } 48 | }; 49 | 50 | const handleRenameMessage = async function (incomingTextMessage) { 51 | const operation = operations.rename; 52 | STATE.current = STATE.waiting; 53 | 54 | if (await handleCancel(incomingTextMessage, operation.onCancelMessage)) 55 | return; 56 | 57 | logInput(operation.logInputMessage, incomingTextMessage.text); 58 | 59 | try { 60 | await getLastMessage().renamePage(incomingTextMessage.text); 61 | handleOperationSuccess(incomingTextMessage.chat.id, operation); 62 | } catch (error) { 63 | handleError(error, incomingTextMessage.chat.id); 64 | } 65 | }; 66 | 67 | const handleDetailsMessage = async function (incomingTextMessage) { 68 | const operation = operations.details; 69 | STATE.current = STATE.waiting; 70 | 71 | if (await handleCancel(incomingTextMessage, operation.onCancelMessage)) 72 | return; 73 | logInput(operation.logInputMessage, incomingTextMessage.text); 74 | 75 | try { 76 | await getLastMessage().addTextBlock(incomingTextMessage.text); 77 | handleOperationSuccess(incomingTextMessage.chat.id, operation); 78 | } catch (error) { 79 | handleError(error, incomingTextMessage.chat.id); 80 | } 81 | }; 82 | 83 | module.exports = { 84 | handleNewMessage, 85 | handleRenameMessage, 86 | handleDetailsMessage, 87 | }; 88 | -------------------------------------------------------------------------------- /src/bot/keyboards.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module exports functions used to generate keyboards like operation keyboard and data keyboards 3 | */ 4 | 5 | const messagesHistory = require('./messages-history'); 6 | 7 | /** 8 | Returns the operations keyboard which includes buttons with their respective callback data. 9 | The buttons on the keyboard include Rename, Move, Add, Unbox, Delete, and Done. 10 | Additionally, if the last message in the message history is saved to a database that has a Topic or a Project, 11 | then the keyboard will also include Add Topic or Add Project, respectively. 12 | @returns {object} Returns an inline keyboard object. 13 | */ 14 | const getOperationsKeyboard = function () { 15 | const lastMessage = messagesHistory[messagesHistory.length - 1]; 16 | 17 | const buttons = [ 18 | [ 19 | { text: '✏️ Rename', callback_data: 'operation#rename' }, 20 | { text: '➡️ Move', callback_data: 'operation#move' }, 21 | { text: '📝 Add', callback_data: 'operation#details' }, 22 | ], 23 | [ 24 | { text: '📤 Unbox', callback_data: 'operation#outbox' }, 25 | { text: '🗑️ Delete', callback_data: 'operation#delete' }, 26 | { text: '✅ Done', callback_data: 'operation#done' }, 27 | ], 28 | ]; 29 | 30 | if (lastMessage.database.hasTopic && lastMessage.database.hasProject) { 31 | buttons.push([ 32 | { text: '🔵 Add Topic', callback_data: 'operation#addTopic' }, 33 | { 34 | text: '💼 Add Project', 35 | callback_data: 'operation#addProject', 36 | }, 37 | ]); 38 | } else if (lastMessage.database.hasTopic) { 39 | buttons.push([ 40 | { text: '🔵 Add Topic', callback_data: 'operation#addTopic' }, 41 | ]); 42 | } else if (lastMessage.database.hasProject) { 43 | buttons.push([ 44 | { 45 | text: '💼 Add Project', 46 | callback_data: 'operation#addProject', 47 | }, 48 | ]); 49 | } 50 | 51 | const replyMarkup = { 52 | inline_keyboard: buttons, 53 | }; 54 | 55 | return replyMarkup; 56 | }; 57 | 58 | /** 59 | 60 | Returns an inline keyboard generated from a list 61 | Used to display data like available database to move a page into or available topics to add to a page. 62 | @param {array} list - An array of keys to be displayed on the keyboard. 63 | @param {number} keysPerRow - The number of keys to be displayed per row on the keyboard. 64 | @returns {object} Returns an inline keyboard object. 65 | */ 66 | const getKeyboardFromList = function (list, keysPerRow = 2) { 67 | const buttons = []; 68 | const totalKeys = list.length; 69 | const rowsCount = Math.ceil(totalKeys / keysPerRow); 70 | 71 | let addedRows = 0; 72 | let addedKeys = 0; 73 | 74 | while (addedRows < rowsCount) { 75 | const row = []; 76 | while (row.length < keysPerRow && addedKeys < totalKeys) { 77 | const key = list[addedKeys]; 78 | 79 | row.push({ 80 | text: key.name, 81 | callback_data: key.callbackData, 82 | }); 83 | addedKeys++; 84 | } 85 | 86 | buttons.push(row); 87 | addedRows++; 88 | } 89 | buttons.push([{ text: '🔙 Back', callback_data: 'operation#back' }]); 90 | 91 | const replyMarkup = { 92 | inline_keyboard: buttons, 93 | }; 94 | 95 | return replyMarkup; 96 | }; 97 | 98 | module.exports = { getOperationsKeyboard, getKeyboardFromList }; 99 | -------------------------------------------------------------------------------- /src/bot/messages-history.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Stores all the created Notion pages for updating and retrieving purposes 3 | */ 4 | module.exports = []; 5 | -------------------------------------------------------------------------------- /src/bot/middleware.js: -------------------------------------------------------------------------------- 1 | /* This module provides a middleware function for checking whether 2 | * the incoming message comes from an authorized user. 3 | */ 4 | 5 | const MY_USER_ID = Number(process.env.MY_USER_ID); 6 | 7 | /** 8 | * verifies the message comes from my chat id only 9 | * @param {Object} message The incoming message from the chat to the bot. 10 | * @param {Number} userId The user id allowed to access the bot 11 | * @return {boolean} true if the message have the same user id and false otherwise 12 | */ 13 | function isAuthorized(message, userId) { 14 | return message.from.id === userId; 15 | } 16 | 17 | /** 18 | 19 | Checks if the message is authorized to be handled by the bot 20 | @param {Function} handler The function that handles the incoming message 21 | @return {Function} The authorized function that handles the incoming message 22 | */ 23 | function checkAuthorization(handler) { 24 | return function (msg) { 25 | if (!isAuthorized(msg, MY_USER_ID)) { 26 | return; 27 | } 28 | handler.apply(this, arguments); 29 | } 30 | } 31 | 32 | module.exports = { checkAuthorization }; -------------------------------------------------------------------------------- /src/bot/state.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The STATE object provides a way to keep track of the current state of the conversation, as a way to direct the text messages to the appropriate handler 3 | */ 4 | const STATE = { 5 | waiting: 'waiting', 6 | rename: 'rename', 7 | details: 'details', 8 | selecting: 'selecting', 9 | current: 'waiting', 10 | lastKeyboardMessage: null, 11 | }; 12 | 13 | module.exports = { STATE }; 14 | -------------------------------------------------------------------------------- /src/bot/utils.js: -------------------------------------------------------------------------------- 1 | const { parse } = require('../lib/Util'); 2 | const { logSuccess } = require('../lib/logger'); 3 | const bot = require('./bot'); 4 | const { getOperationsKeyboard } = require('./keyboards'); 5 | const messagesHistory = require('./messages-history'); 6 | const { STATE } = require('./state'); 7 | 8 | /** 9 | 10 | Sends a success message to the chat with the given chat ID, using the operations keyboard if specified 11 | @param {number} chatId - The ID of the chat to send the message to 12 | @param {string} replyMessage - The message to send 13 | @param {boolean} useOperationsKeyboard - Whether or not to include the operations keyboard for further interaction 14 | @returns {Promise} 15 | */ 16 | const sendSuccessMessage = async function ( 17 | chatId, 18 | replyMessage, 19 | useOperationsKeyboard = true 20 | ) { 21 | if (!useOperationsKeyboard) { 22 | await bot.sendMessage(chatId, replyMessage); 23 | return; 24 | } 25 | const lastSentKeyboard = await bot.sendMessage(chatId, replyMessage, { 26 | parse_mode: 'Markdown', 27 | reply_markup: getOperationsKeyboard(), 28 | }); 29 | STATE.current = STATE.selecting; 30 | STATE.lastKeyboardMessage = lastSentKeyboard; 31 | }; 32 | 33 | /** 34 | 35 | Handles a successful operation by sending a success message to the chat and logging a success message 36 | @param {number} chatId - The ID of the chat to send the message to 37 | @param {Object} operation - The operation that was successful 38 | @param {boolean} useOperationsKeyboard - Whether or not to include the operations keyboard for further interaction 39 | @returns {Promise} 40 | */ 41 | const handleOperationSuccess = async function ( 42 | chatId, 43 | operation, 44 | useOperationsKeyboard = true 45 | ) { 46 | const lastMessage = getLastMessage(); 47 | const pageName = lastMessage.properties.Name.title[0].text.content; 48 | const notionURL = lastMessage.notionURL; 49 | const replyMessage = parse(operation.successMessage, pageName, notionURL); 50 | await sendSuccessMessage(chatId, replyMessage, useOperationsKeyboard); 51 | logSuccess(operation.logSuccessMessage, pageName, notionURL); 52 | }; 53 | 54 | /** 55 | 56 | Checks if the incoming text message is '/cancel', and sends the specified reply message along with the operations keyboard 57 | @param {Object} incomingTextMessage - The incoming text message to check 58 | @param {string} replyMessage - The message to send if the incoming message is '/cancel' 59 | @returns {Promise} - Whether or not the incoming message was '/cancel' 60 | */ 61 | const handleCancel = async function (incomingTextMessage, replyMessage) { 62 | if (incomingTextMessage.text === '/cancel') { 63 | bot.sendMessage(incomingTextMessage.chat.id, replyMessage, { 64 | parse_mode: 'Markdown', 65 | reply_markup: getOperationsKeyboard(), 66 | }); 67 | return true; 68 | } 69 | return false; 70 | }; 71 | 72 | /** 73 | 74 | Returns the last message in the messagesHistory array 75 | @returns {Object} - The last message in the messagesHistory array 76 | */ 77 | const getLastMessage = () => messagesHistory[messagesHistory.length - 1]; 78 | 79 | module.exports = { 80 | handleCancel, 81 | getLastMessage, 82 | handleOperationSuccess, 83 | }; 84 | -------------------------------------------------------------------------------- /src/classes/Message.js: -------------------------------------------------------------------------------- 1 | // Basic message has a text 2 | // and optional database 3 | 4 | module.exports = class Message { 5 | constructor({ text, database }) { 6 | this.text = text; 7 | this.database = database; 8 | } 9 | 10 | process() { 11 | return new Promise(function (resolve, reject) { 12 | resolve(); 13 | }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/classes/MessageProcessor.js: -------------------------------------------------------------------------------- 1 | // takes a bot message and cuts into tag(db), text, url 2 | const Util = require('../lib/Util'); 3 | const data = require('../databases/store'); 4 | 5 | module.exports = class MessageProcessor { 6 | constructor(incomingMessage) { 7 | this.text = incomingMessage; 8 | this._process(incomingMessage); 9 | } 10 | 11 | _process(incomingMessage) { 12 | const { databases } = data; 13 | this.database = 14 | this._findMatchingDatabase(incomingMessage, databases) || databases.stuff; 15 | this.text = Util.cleanUpTheMessage(this.database.tag, this.text); 16 | 17 | this.url = this._extractUrlFromText(this.text); 18 | if (this.url) { 19 | this.text = Util.cleanUpTheMessage(this.url, this.text); 20 | } 21 | } 22 | 23 | // Identify the database based on the tag in the message. 24 | _findMatchingDatabase = (message, databases) => { 25 | const matchingDatabase = Object.keys(databases).find( 26 | (databaseName) => 27 | message.includes(databases[databaseName].tag) && 28 | databases[databaseName].tag !== '' 29 | ); 30 | return databases[matchingDatabase]; 31 | }; 32 | 33 | // Detach the URL from the message text. 34 | _extractUrlFromText = (message) => { 35 | const urlRegex = /(https?:\/\/[^ ]*)/; 36 | const matchingResults = message.match(urlRegex); 37 | return matchingResults ? matchingResults[0] : null; 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/classes/URLMessage.js: -------------------------------------------------------------------------------- 1 | const Message = require('./Message'); 2 | const Webpage = require('./WebPage'); 3 | const data = require('../databases/store'); 4 | 5 | module.exports = class URLMessage extends Message { 6 | constructor(processedMessage) { 7 | super(processedMessage); 8 | 9 | this.url = processedMessage.url; 10 | this.title = null; 11 | this.useWebSite = false; 12 | this._process(); 13 | } 14 | 15 | _process = () => { 16 | const { databases, websites } = data; 17 | this.website = this._findMatchingWebsite(websites) || null; 18 | //Only change the database if no one was assigned via tags 19 | if (this.website && this.database === databases.stuff) { 20 | this.database = this.website.parent; 21 | this.useWebSite = true; 22 | } 23 | this.webpage = this._generateWebPage(); 24 | }; 25 | 26 | //match url with a website 27 | _findMatchingWebsite = (websites) => { 28 | const matchedWebsite = Object.keys(websites).filter((websiteName) => 29 | this.url.match(websites[websiteName].downloadHTMLRules.urlPattern) 30 | ); 31 | return websites[matchedWebsite]; 32 | }; 33 | 34 | _generateWebPage = () => { 35 | const parsingRules = this.website ? this.website.downloadHTMLRules : null; 36 | return new Webpage(this.url, parsingRules); 37 | }; 38 | 39 | process = async () => { 40 | await this.webpage.process(); 41 | this.title = this.webpage.title; 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/classes/WebPage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a webpage. 3 | * @class 4 | */ 5 | 6 | const httpClient = require('axios'); 7 | const HTMLParser = require('node-html-parser'); 8 | const { logError } = require('../lib/logger'); 9 | 10 | const HTML_TITLE_TAG = 'title'; 11 | const HTML_HEADER_TAG = 'h1'; 12 | const FALLBACK_URL_TITLE = 'New URL'; 13 | 14 | module.exports = class Webpage { 15 | constructor(url, parsingRules) { 16 | this.url = url; 17 | this.parsingRules = parsingRules; 18 | } 19 | 20 | async _downloadPage(url) { 21 | try { 22 | const response = await httpClient.get(url); 23 | this.HTMLContent = response.data; 24 | } catch (error) { 25 | logError('Error occurred while downloading page'); 26 | return error; 27 | } 28 | } 29 | 30 | _parseHTML(content) { 31 | return HTMLParser.parse(content); 32 | } 33 | 34 | _setTitle() { 35 | if ( 36 | this.parsingRules && 37 | this.parsingRules.titleHtmlTag && 38 | this.html.querySelector(this.parsingRules.titleHtmlTag) 39 | ) { 40 | this.title = this.html.querySelector(this.parsingRules.titleHtmlTag).text; 41 | } else { 42 | const titleTag = 43 | this.html.querySelector(HTML_TITLE_TAG) && 44 | this.html.querySelector(HTML_TITLE_TAG).text; 45 | const headerTag = 46 | this.html.querySelector(HTML_HEADER_TAG) && 47 | this.html.querySelector(HTML_HEADER_TAG).text; 48 | this.title = titleTag || headerTag || FALLBACK_URL_TITLE; 49 | } 50 | 51 | if (this.parsingRules && this.parsingRules.titlePattern) { 52 | this.title = this.title.replace(this.parsingRules.titlePattern, ''); 53 | } 54 | this.title = this.title.trim(); 55 | } 56 | 57 | async process() { 58 | try { 59 | await this._downloadPage(this.url); 60 | this.html = this._parseHTML(this.HTMLContent, this.HTMLParser); 61 | this._setTitle(); 62 | } catch (error) { 63 | logError('Error occurred while running'); 64 | return error; 65 | } 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /src/classes/notion/NotionDatabase.js: -------------------------------------------------------------------------------- 1 | const { logProgress } = require('../../lib/logger'); 2 | const { Client } = require('@notionhq/client'); 3 | const notionClient = new Client({ auth: process.env.NOTION_TOKEN }); 4 | 5 | module.exports = class NotionDatabase { 6 | constructor(options) { 7 | this.setId(options.id); 8 | this.setName(options.name); 9 | this.setIcon(options.icon); 10 | this.setFilter(options.filter); 11 | this.setTag(options.tag); 12 | this.setHasTopic(options.hasTopic); 13 | this.setHasProject(options.hasProject); 14 | this.setPath(options.path); 15 | this.websites = []; 16 | } 17 | setName(name) { 18 | this.name = name; 19 | } 20 | setId(id) { 21 | this.id = id; 22 | } 23 | setIcon(icon) { 24 | this.icon = icon; 25 | } 26 | setFilter(filter) { 27 | if (!filter) { 28 | return; 29 | } 30 | const search = "'"; 31 | const replaceWith = '"'; 32 | filter = filter.split(search).join(replaceWith); 33 | this.filter = JSON.parse(filter); 34 | } 35 | setTag(tag) { 36 | this.tag = tag; 37 | } 38 | setHasTopic(hasTopic) { 39 | this.hasTopic = hasTopic; 40 | } 41 | setHasProject(hasProject) { 42 | this.hasProject = hasProject; 43 | } 44 | setPath(path) { 45 | this.path = path; 46 | } 47 | 48 | getName() { 49 | return this.name; 50 | } 51 | getId() { 52 | return this.id; 53 | } 54 | getIcon() { 55 | return this.icon; 56 | } 57 | getFilter() { 58 | return this.filter; 59 | } 60 | getTag() { 61 | return this.tag; 62 | } 63 | getHasTopic() { 64 | return this.hasTopic; 65 | } 66 | getHasProject() { 67 | return this.hasProject; 68 | } 69 | getPath() { 70 | return this.path; 71 | } 72 | 73 | addWebsite(website) { 74 | this.websites.push(website); 75 | } 76 | 77 | query = async () => { 78 | logProgress('querying a notion database'); 79 | return notionClient.databases.query({ 80 | database_id: this.id, 81 | filter: this.filter.filter, 82 | }); 83 | }; 84 | }; 85 | -------------------------------------------------------------------------------- /src/classes/notion/NotionPage.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const NotionPageProperties = require('./NotionPageProperties'); 3 | const NotionPageChildren = require('./NotionPageChildren'); 4 | const { logProgress } = require('../../lib/logger'); 5 | const { Client } = require('@notionhq/client'); 6 | const notionClient = new Client({ auth: process.env.NOTION_TOKEN }); 7 | 8 | module.exports = class NotionPage { 9 | constructor(message) { 10 | this.database = message.database; 11 | this._setParent(message.database.id); 12 | this._setIcon(message); 13 | this._setProperties(message); 14 | if (message.title && message.text) { 15 | this._setChildren(message); 16 | } 17 | } 18 | 19 | _setParent = (id) => { 20 | this.parent = { 21 | type: 'database_id', 22 | database_id: id, 23 | }; 24 | }; 25 | 26 | _setIcon = (message) => { 27 | this.icon = message.website 28 | ? this._generateExternalIcon(message.website.notionRules.defaultIcon) 29 | : this._generateEmojiIcon(message.database.icon); 30 | }; 31 | 32 | _generateEmojiIcon = (emoji) => { 33 | return { 34 | type: 'emoji', 35 | emoji, 36 | }; 37 | }; 38 | 39 | _generateExternalIcon = (url) => { 40 | return { 41 | type: 'external', 42 | external: { 43 | url, 44 | }, 45 | }; 46 | }; 47 | 48 | _prepareOptions = (url, notionRules) => { 49 | const { mediaType, Tags } = notionRules 50 | ? notionRules 51 | : { undefined, undefined }; 52 | return { 53 | url, 54 | mediaType, 55 | Tags, 56 | }; 57 | }; 58 | 59 | _setProperties = (message) => { 60 | const title = message.title || message.text; 61 | const notionRules = 62 | message.website && message.website.notionRules && message.useWebSite 63 | ? message.website.notionRules 64 | : null; 65 | const options = this._prepareOptions(message.url, notionRules); 66 | this.properties = new NotionPageProperties(title, options); 67 | }; 68 | 69 | _setChildren = (message) => { 70 | //TODO:Handel details and embeds 71 | 72 | // If the message has text save it as a paragraph inside th created page 73 | this.children = new NotionPageChildren({ text: message.text }).children; 74 | }; 75 | 76 | createNewPage = () => { 77 | logProgress('Creating a notion page'); 78 | return notionClient.pages.create(this); 79 | }; 80 | 81 | renamePage = async (newName) => { 82 | logProgress('Renaming a notion page'); 83 | this.properties.Name.title[0].text.content = newName; 84 | return notionClient.pages.update({ 85 | page_id: this.id, 86 | properties: this.properties.Name, 87 | }); 88 | }; 89 | 90 | outbox = () => { 91 | logProgress('outbox a notion page'); 92 | this.properties.Inbox.checkbox = false; 93 | return notionClient.pages.update({ 94 | page_id: this.id, 95 | properties: this.properties, 96 | }); 97 | }; 98 | 99 | move = async (newParent) => { 100 | //Deleting and re-creating cause changing the parent is not available in the Notion API 101 | // more at: https://developers.notion.com/reference/patch-page 102 | logProgress('moving a notion page'); 103 | await this.delete(); 104 | this._setParent(newParent.id); 105 | this.database = newParent; 106 | const notionResponse = await this.createNewPage(this); 107 | this.id = notionResponse.id; 108 | this.notionURL = notionResponse.url; 109 | return notionResponse; 110 | }; 111 | 112 | addTextBlock = async (text) => { 113 | logProgress('Adding text block'); 114 | //TODO: make sure the child is added to this object too 115 | return notionClient.blocks.children.append({ 116 | block_id: this.id, 117 | children: new NotionPageChildren({ text }).children, 118 | }); 119 | }; 120 | 121 | addRelation = async (relationName, relatedId) => { 122 | logProgress('Adding ' + relationName); 123 | this.properties._addProperty( 124 | relationName, 125 | this.properties._createRelationProperty(relatedId) 126 | ); 127 | return notionClient.pages.update({ 128 | page_id: this.id, 129 | properties: this.properties, 130 | }); 131 | }; 132 | 133 | delete = () => { 134 | logProgress('Deleting a notion page'); 135 | return notionClient.pages.update({ 136 | page_id: this.id, 137 | archived: true, 138 | }); 139 | }; 140 | }; 141 | -------------------------------------------------------------------------------- /src/classes/notion/NotionPageChildren.js: -------------------------------------------------------------------------------- 1 | module.exports = class NotionPageChildren { 2 | constructor(options) { 3 | this.children = []; 4 | options.text ? this._addParagraphChild(options.text) : null; 5 | } 6 | 7 | _addParagraphChild = (text) => { 8 | this.children.push({ 9 | object: 'block', 10 | paragraph: { 11 | rich_text: [ 12 | { 13 | text: { 14 | content: text, 15 | }, 16 | }, 17 | ], 18 | color: 'default', 19 | }, 20 | }); 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/classes/notion/NotionPageProperties.js: -------------------------------------------------------------------------------- 1 | module.exports = class NotionPageProperties { 2 | constructor(title, options) { 3 | this._addProperty('Name', this._createTitleProperty(title)); 4 | this._addProperty('Inbox', this._createCheckboxProperty(true)); 5 | this._buildOptionalProperties(options); 6 | } 7 | 8 | _addProperty = (propName, prop) => { 9 | this[propName] = prop; 10 | }; 11 | 12 | _createTitleProperty = (title) => { 13 | return { 14 | title: [ 15 | { 16 | text: { 17 | content: title, 18 | }, 19 | }, 20 | ], 21 | }; 22 | }; 23 | 24 | _createCheckboxProperty = (checkbox) => { 25 | return { 26 | type: 'checkbox', 27 | checkbox, 28 | }; 29 | }; 30 | 31 | _createSelectProperty = (selectedOption) => { 32 | return { 33 | select: { 34 | name: selectedOption, 35 | }, 36 | }; 37 | }; 38 | 39 | _createMultiSelectProperty = (Tags) => { 40 | //TODO: make it accept multiple tags 41 | return { 42 | multi_select: [ 43 | { 44 | name: Tags, 45 | }, 46 | ], 47 | }; 48 | }; 49 | 50 | _createURLProperty = (url) => { 51 | return { 52 | url, 53 | }; 54 | }; 55 | 56 | _createTextProperty = (text) => { 57 | return { 58 | rich_text: [ 59 | { 60 | text: { 61 | content: text, 62 | }, 63 | }, 64 | ], 65 | }; 66 | }; 67 | 68 | _createRelationProperty = (id) => { 69 | return { 70 | relation: [{ id }], 71 | }; 72 | }; 73 | 74 | _buildOptionalProperties(options) { 75 | if (options.url) { 76 | this._addProperty('URL', this._createURLProperty(options.url)); 77 | } 78 | if (options.Tags) { 79 | this._addProperty('Tags', this._createMultiSelectProperty(options.Tags)); 80 | } 81 | if (options.mediaType) { 82 | this._addProperty( 83 | 'Media Type', 84 | this._createSelectProperty(options.mediaType) 85 | ); 86 | } 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/classes/notion/Website.js: -------------------------------------------------------------------------------- 1 | module.exports = class Website { 2 | 3 | constructor(options, databases) { 4 | 5 | this.setParentDB(options.parent, databases); 6 | this.setName(options.name); 7 | this.setDownloadHTMLRules(options.urlPattern, 8 | options.titleHtmlTag, options.titlePattern, options.selenium); 9 | 10 | this.setNotionRules(options.typeSelectOption, options.mediaType, options.Tags, options.defaultIcon, options.defaultTitle, options.embeddableContent, options.embeddableVideo); 11 | } 12 | 13 | setName(name) { 14 | this.name = name; 15 | } 16 | 17 | setParentDB(parent, databases) { 18 | this.parent = databases[parent]; 19 | databases[parent].addWebsite(this); 20 | } 21 | 22 | setDownloadHTMLRules(urlPattern, titleHtmlTag, titlePattern, selenium) { 23 | this.downloadHTMLRules = { 24 | titleHtmlTag, 25 | titlePattern, 26 | selenium, 27 | urlPattern 28 | 29 | } 30 | } 31 | 32 | setNotionRules(typeSelectOption, mediaType, Tags, defaultIcon, defaultTitle, embeddableContent, embeddableVideo) { 33 | this.notionRules = { 34 | typeSelectOption, 35 | defaultIcon, 36 | defaultTitle, 37 | embeddableContent, 38 | embeddableVideo, 39 | mediaType, 40 | Tags 41 | } 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/databases/data-retriever.js: -------------------------------------------------------------------------------- 1 | const { logSuccess } = require('../lib/logger'); 2 | const NotionDatabase = require('../classes/notion/NotionDatabase'); 3 | const Website = require('../classes/notion/Website'); 4 | const store = require('./store'); 5 | const db = require('./db'); 6 | 7 | /** 8 | * Retrieves "Notion databases" data from the database 9 | * @returns {Promise} - Resolves with an object of database objects. 10 | */ 11 | const getDatabase = function () { 12 | return new Promise(function (resolve, reject) { 13 | const DATABASES = {}; 14 | const query = `SELECT * FROM bases`; 15 | db.each( 16 | query, 17 | 18 | (err, row) => { 19 | if (err) { 20 | reject(err); 21 | } else { 22 | DATABASES[row.name] = new NotionDatabase(row); 23 | } 24 | }, 25 | (err, n) => { 26 | if (err) { 27 | reject(err); 28 | } else { 29 | resolve(DATABASES); 30 | } 31 | } 32 | ); 33 | }); 34 | }; 35 | 36 | /** 37 | * Retrieves Websites data from the database. 38 | * @param {Object} databases - Object containing all databases.b 39 | * @returns {Promise} Promise that resolves to an object containing all the websites. 40 | */ 41 | const getWebsites = function (databases) { 42 | return new Promise(function (resolve, reject) { 43 | const websites = {}; 44 | const query = `SELECT * FROM websites `; 45 | 46 | db.each( 47 | query, 48 | 49 | (err, row) => { 50 | if (err) { 51 | reject(err); 52 | } else { 53 | websites[row.name] = new Website(row, databases); 54 | } 55 | }, 56 | (err, n) => { 57 | if (err) { 58 | reject(err); 59 | } else { 60 | db.close(); 61 | resolve(websites); 62 | } 63 | } 64 | ); 65 | }); 66 | }; 67 | 68 | module.exports = async function () { 69 | const databases = await getDatabase(); 70 | store.databases = databases; 71 | logSuccess('Loaded databases!'); 72 | const websites = await getWebsites(databases); 73 | store.websites = websites; 74 | logSuccess('Loaded websites!'); 75 | }; 76 | -------------------------------------------------------------------------------- /src/databases/db.js: -------------------------------------------------------------------------------- 1 | // Import the SQLite3 library and instantiate a new database connection 2 | 3 | const sqlite3 = require('sqlite3').verbose(); 4 | const db = new sqlite3.Database('./notion.sqlite'); 5 | 6 | module.exports = db; 7 | -------------------------------------------------------------------------------- /src/databases/store.js: -------------------------------------------------------------------------------- 1 | // Stores the data retrieved from the database at the initialization stage to be used across the app 2 | 3 | module.exports = {}; 4 | -------------------------------------------------------------------------------- /src/lib/Util.js: -------------------------------------------------------------------------------- 1 | module.exports = class Util { 2 | static cleanUpTheMessage = (additionalInfo, text) => { 3 | return text.replace(additionalInfo, '').trim(); 4 | }; 5 | 6 | static parse(str, ...args) { 7 | let i = 0; 8 | return str.replace(/%s/g, () => args[i++]); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/lib/logger.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const parse = require('./Util').parse; 3 | 4 | module.exports = class Logger { 5 | static logSuccess = (message, ...args) => { 6 | console.log(chalk.green.italic(`✅ ${parse(message, ...args)}`)); 7 | }; 8 | static logInput = (message, ...args) => { 9 | console.log(chalk.cyan.underline(`📩 ${parse(message, args)}`)); 10 | }; 11 | static logError = (message) => { 12 | console.error(chalk.red(message)); 13 | }; 14 | 15 | static logProgress = (message, ...args) => { 16 | console.error(chalk.yellow(`🚧 ${parse(message, args)}...`)); 17 | }; 18 | 19 | static logDebug = (message) => { 20 | console.error( 21 | chalk.bgMagenta('**************************************************') 22 | ); 23 | console.log(message); 24 | console.error( 25 | chalk.bgMagenta('**************************************************') 26 | ); 27 | }; 28 | }; 29 | --------------------------------------------------------------------------------