Building personal assistant bot with telegram

Bot Arm

source

In this post I’ll document what I’ve done to build a telegram bot that helps me track most of useful things i know, do, willing to share with public.

Motivation

I’ve created a wiki to share my bookmarks, links, articles, tools, …etc, but the problem was when you find good link or anything and want to add it to the wiki, in this situation I was open Gitbook dashboard, and searching for the document that i want to add to, then push and sync. That was taking a lot of time, and i had have to do this from my laptop super boring, isn’t it?

The problem appears strongly when reading a book and add comments or notes, and want to track it or share it, similar thing to my IMDB reviews. All of this was annoying me to add to it, so I decided to build a bot that can collect these things and add them whenever I tell him to do so.

Features

Okay, now I’ve a problem with an idea to solve it. The first thing i was supposed to do is listing the main commands for this bot to understand.

  • /syncIMDB List all Movies/TV Series/Video Games ratings from IMDB.
  • /syncNotion Publishing pages to notes.zeyadetman.com from Notion.

Starting the Bot Implementation

So while I was thinking about how can i organize the bot object and its behaviors, I ended up with implement the Decorator design pattern.

Decorator design pattern: In this pattern you have 4 main things:

  1. The main interface for your component and basic methods “without implementation”, in my case it’s the Bot.
  2. The main concrete component that implements the default behavior of component behaviors.
  3. The base decorator that act like a wrapper for concrete decorators.
  4. The concrete decorator is to call the object and alter it.

Why? I have a monolithic class (Bot) with a bunch of behaviors and actions, like commands, configurations, messages, …etc, so i want to divide them into smaller classes that each class contains one behavior or action.

I split the main features into multiple concrete decorators.

  • One to handle the BotCommands , every command starting with /
export class BotWithCommands extends DecoratorBot {
  private setCommands(bot) {
    bot.command("syncIMDB", syncIMDB);
    bot.start(start);
    bot.help(help);
  }

  public launch() {
    this.setCommands(this.bot);
    super.launch();
  }
}
import { DecoratorBot } from "../decorator";
import { documentEvent } from "./document";

export class BotWithEvents extends DecoratorBot {
  private setEvents(bot) {
    bot.on("document", documentEvent);
    bot.on("sticker", (ctx: any) => ctx.reply("👍"));
  }

  public launch() {
    this.setEvents(this.bot);
    super.launch();
  }
}

You can read the code to understand more about the implementation and the idea for these decorators.

The final structure was something like this

.
├── commands
│   ├── help.ts
│   ├── index.ts
│   ├── start.ts
│   ├── syncIMDB.ts
│   └── syncNotion.ts
├── decorator
│   └── index.ts
├── events
│   ├── document.ts
│   └── index.ts
├── index.ts
├── types
└── utils
    ├── constants.ts
    ├── convert-name.ts
    └── writeFileToGithub.ts

/syncIMDB Create a list of my movies ratings

This was the first thing i handled in the bot. The problem is I need to show a list of all my movies ratings, I use IMDB to track this, but IMDB doesn’t offer apis to get my ratings, So the solution that i use is exporting the ratings manually, and send this file to the bot telling him to update this file to re-format it into a json file, then add this file to the notes app, and GitHub action will redeploy the app updating the movies list on my notes app with these new ratings.

I use nivo.rocks to visualize the genres and make the list more interactive. Please read my comment on how I implemented it.

This was a little bit tricky especially because I’m using docusaurus.io but you can check the code for it from here.

I use the same json file to add more pages with the same style.

/syncNotion Notion integration

The second thing is getting pages from notion. So the idea is creating a database on notion that contains multiple nested pages with their slugs/paths and this command will get the database by its id and fetching all pages under this databases including nested databases, then create commits with the files on Notes Repo on GitHub. That’s the idea I’ve came with.

Here’s the code for it.

const handleDatabases = async (
  databaseId: string,
  pagesToPublish: any,
  parentSlug = ""
) => {
  return new Promise(async (resolve, reject) => {
    try {
      const { results } = await notion.databases.query({
        database_id: databaseId,
      });
      const pages = results.filter((res) => res.object === "page");
      pages.forEach(async (page: any) => {
        if (page?.properties?.Slug?.rich_text?.[0]?.plain_text) {
          pagesToPublish[page.properties.Slug.rich_text[0].plain_text] = {
            slug: page.properties.Slug.rich_text[0].plain_text,
            isPublish: page.properties?.Published?.checkbox || false,
            path: `${parentSlug || ""}${
              page.properties.Slug.rich_text[0].plain_text
            }`,
            page,
          };
        } else {
          return ctx.reply("Please add a slug to the page");
        }
      });

      for await (let page of Object.values(pagesToPublish)) {
        const { slug, page: pageInfo } = page as any;
        const mdblocks = await n2m.pageToMarkdown(pageInfo.id);
        const finalMdBlocks = [];
        mdblocks?.forEach(async (block: any, index) => {
          if (index === mdblocks?.length - 1) {
            return;
          }
          if (block.type === "child_database") {
            const databaseId = block.parent.split("(").pop().split(")")[0];
            pagesToPublish[slug].children = {
              databaseId,
              slug,
              parentSlug: `${parentSlug || ""}${slug}`,
            };
          } else {
            finalMdBlocks.push(block);
          }
        });

        pagesToPublish[slug].mdBlocks = finalMdBlocks;
      }

      for await (let page of Object.keys(pagesToPublish)) {
        const { children, slug } = pagesToPublish[page] as any;
        if (children?.databaseId) {
          pagesToPublish[slug].childs = await handleDatabases(
            children.databaseId,
            {},
            `${children.parentSlug}`
          );
        }
      }

      console.log(util.inspect(pagesToPublish, false, null, true));
      return resolve(pagesToPublish);
    } catch (error) {
      return reject(error.message);
    }
  });
};

And that’s works great with a sample of notion database like this:

So if the page’s Published property is false, then the page will not be published to github repo, and if the page doesn’t have a Slug property it should gives an error, because the slug is a path of that file.

The final command on telegram gave me this reply

Wow! that’s totally awesome 😎


Until then I’ll let this post ends in this point and will update it whenever I add new feature.

…to be continued



Updated at Tue Sep 28 2021