├── .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: 'ExampleHello, 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 titleSome 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