Contents

How to send notifications of blog posts to a Telegram channel

In recent days I was thinking of enabling a way to send notifications to my contacts in Telegram each time I published a new post in my personal blog, which is a static website generated with Hugo. The option that first comes to mind is to set up a Telegram channel —the issue here is that having to manually go and post the link in the channel each time new content has been published is a tedious task. I know, I know, that’s a lazy mindset. But how far would we have gotten in the field of programming without a bit of laziness?

In this post I discuss a way to send automatic notifications of new posts to a Telegram channel. Although I originally implemented this for my personal blog, for this post I’ll use this website and its source code.

Roadmap

Objective: Send notifications of new blog posts to a Telegram channel

Steps
  • Get the secrets needed to interact with Telegram’s API
  • Create a Telegram bot
  • Add the bot as admin to a Telegram channel
  • Write a Python script to send messages with the bot
  • Customize the script to send messages containing the links of new blog posts
  • Write a GitHub action to automate the workflow

Get the secrets to interact with Telegram

Since we will be hitting Telegram’s API, we need an API id. Fortunately, the official documentation is pretty straightforward, so this point shouldn’t be an issue. It’s important to store safely both the api_id and the api_hash, we’ll use them later.

Create a bot in Telegram and make it admin of the channel

Next thing is to talk to the @Botfather and ask for a bot. The Botfather is a bot of bots (metabot) —it allows you to create and manage bots. Again, this is a straightforward process, so I’ll skip this part too. After creating the bot we need to store the bot_token. Also, we need to create a channel in Telegram and add the bot as admin. In my case I’m using the public channel @omiranda_dev.

Use the bot to send a “hello world” to the channel

Now that we have a bot and the secrets to hit the API, we’d like to make a little test to see if everything works as expected. We will use Telethon, a Python library to interact with Telegram’s API. To install it:

1
$ pip install telethon

Now we export the secrets to the environment, along with the username of the channel:

1
2
3
4
$ export API_ID = <api_id>
$ export API_HASH = <api_hash>
$ export BOT_TOKEN = <bot_token>
$ export CHANNEL = <channel_username>

The following script sends a hello world message to the target channel. I’ve used the quick start of Telethon as a starting point.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import os
from telethon import TelegramClient


API_ID = os.getenv("API_ID")
API_HASH = os.getenv("API_HASH")
BOT_TOKEN = os.getenv("BOT_TOKEN")
CHANNEL = os.getenv("CHANNEL")
client = TelegramClient('bot', API_ID, API_HASH)
bot = client.start(bot_token=BOT_TOKEN)


async def main():
    message = "hello world"
    await bot.send_message(CHANNEL, message)

with client:
    client.loop.run_until_complete(main())

Save the script in a Python file, say hello_world.py, and execute it:

1
$ python hello_world.py

There should be a hello world in the channel.

We would like to make the script a little bit more complex so that it can send the URL of new posts to the channel. Although there are several approaches to achieve it I’m relying in the frontmatter properties. To parse the frontmatter we can use the python-frontmatter library:

1
$ pip install python-frontmatter

In the context of this website, posts are markdown files in the content/posts directory. The files are located in individual folders, grouped by year/month/name. Thus, the filenames are like content/posts/{year}/{month}/{name}/index.md.

Each index.md file has a set of frontmatter properties. Two of them are important for this exercise: the title of the post and the slug, which is the last component of the URL. For example:

1
2
3
4
5
6
---
title: "My Post"
slug: "my-post"
---

This is my blog post.

When the site gets built and deployed the resulting page is published in {root_url}/{year}/{month}/{slug}.

With all this blah blah blah in mind we can adjust the Python script.

First, we define a Post class to ease obtaining the metadata of posts. To initialize a Post object we need the path of the markdown file. To extract the metadata we use the frontmatter.parse() method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from pathlib import Path
import frontmatter

class Post:
    def __init__(self, filepath: str):
        self.filepath = filepath
        self._metadata = None

    @property
    def metadata(self) -> dict:
        if not self._metadata:
            content_dir = Path(".").resolve()
            with Path(content_dir, self.filepath).open("r", encoding="utf-8") as file:
                metadata, _ = frontmatter.parse(file.read())
            self._metadata = metadata
        return self._metadata

To get the “prefix” (i.e, the year and month) of the URL we can split the filepath and take only those parts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Post:
    def __init__(self, filepath: str):
        self.filepath = filepath
        self._metadata = None
        self._prefix = None
    
    # ...

    @property
    def prefix(self) -> str:
        if not self._prefix:
            index = (file:=Path(self.filepath)).parts.index("posts")
            prefix = str(Path(*file.parts[index + 1:-2]))
            self._prefix = prefix
        return self._prefix

I don’t publish draft posts so I want to prevent the script of notifying about those. Also, we’d like to know if a file is valid —which means that it has the title and slug properties set.

1
2
3
4
5
6
7
    @property
    def is_draft(self) -> bool:
        return "draft" in self.metadata.keys() and self.metadata["draft"]

    @property
    def is_valid(self) -> bool:
        return "slug" in self.metadata.keys() and "title" in self.metadata.keys()

Finally, we get the URL and title of the post.

1
2
3
4
5
6
7
    @property
    def url(self) -> str:
        return f"https://omiranda.dev/{self.page}/{self.metadata['slug']}"

    @property
    def title(self) -> str:
        return self.metadata["title"]

To make the script CLI-like we define a class Main that will take care of sending the messages, if applicable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Main:

    @staticmethod
    def get_message(title: str, url: str) -> str:
        return f"**[{title}]({url})**"

    async def send_message(self, message: str):
        await bot.send_message(CHANNEL, message)

    def notify(self, message: str):
        with client:
            client.loop.run_until_complete(self.send_message(message=message))

    def run(self, filepath: str):
        post = Post(filepath=filepath)
        if not post.is_valid:
            raise ValueError("The frontmatter in the file does not have the necessary properties")
        if post.is_draft:
            return
        message = self.get_message(title=post.title, url=post.url)
        self.notify(message=message)

To fire up the class we use fire, which can be installed with pip.

1
$ pip install fire

In the script:

1
2
3
4
5
6
7
from fire import Fire

# Rest of the code

if __name__ == "__main__":
    main = Main()
    Fire(main)

Putting everything together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
"""This module implements a class to send messages to a Telegram channel"""
import os
from pathlib import Path
import frontmatter
from fire import Fire
from telethon import TelegramClient


API_ID = os.getenv("API_ID")
API_HASH = os.getenv("API_HASH", "")
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
CHANNEL = os.getenv("CHANNEL", "")
client = TelegramClient('bot', API_ID, API_HASH)
bot = client.start(bot_token=BOT_TOKEN)


class Post:
    def __init__(self, filepath: str):
        self.filepath = filepath

    @property
    def metadata(self) -> dict:
        return self._metadata

    @metadata.setter
    def metadata(self):
        content_dir = Path(".").resolve()
        with Path(content_dir, self.filepath).open("r", encoding="utf-8") as file:
            metadata, _ = frontmatter.parse(file.read())
        self._metadata = metadata

    @property
    def prefix(self) -> str:
        return self._prefix

    @prefix.setter
    def prefix(self):
        index = (file:=Path(self.filepath)).parts.index("posts")
        prefix = str(Path(*file.parts[index + 1:-2]))
        self._prefix = prefix

    @property
    def is_draft(self) -> bool:
        return "draft" in self.metadata.keys() and self.metadata["draft"]

    @property
    def is_valid(self) -> bool:
        return "slug" in self.metadata.keys() and "title" in self.metadata.keys()

    @property
    def url(self) -> str:
        return f"https://omiranda.dev/{self.prefix}/{self.metadata['slug']}"

    @property
    def title(self) -> str:
        return self.metadata["title"]


class Main:

    @staticmethod
    def get_message(title: str, url: str) -> str:
        return f"**[{title}]({url})**"

    async def send_message(self, message: str):
        await bot.send_message(CHANNEL, message)

    def notify(self, message: str):
        with client:
            client.loop.run_until_complete(self.send_message(message=message))

    def run(self, filepath: str):
        post = Post(filepath=filepath)
        if not post.is_valid:
            raise ValueError("The frontmatter in the file does not have the necessary properties")
        if post.is_draft:
            return
        message = self.get_message(title=post.title, url=post.url)
        self.notify(message=message)


if __name__ == "__main__":
    main = Main()
    Fire(main)

We can test the behavior of the modified script. This time I’m saving it in notify_telegram.py.

1
$ python notify_telegram.py run --filepath "content/posts/2024/06/telegram-notifications/index.md"

This sends a message to the channel with the link of the post. However, the post isn’t live yet. To build and deploy the site I have set up a GitHub Action deploy.yml which takes care of that. In the target repository I have set up GitHub Pages which publishes the site each time new changes arrive. With these activities covered the only thing left is to write a GitHub action to notify of new posts.

GitHub action to notify of new posts

“New posts” are any number of markdown files with a matching filename that were added in the last commit. So, we need to make sure that the notification script is executed only if new files were added. We can define a GitHub action to do that.

I’d like to notify of new posts each time I push a commit to the main branch in the remote. It would also be useful to execute the notification script manually. To trigger the workflow under these scenarios we need to set up the push and workflow_dispatch events.

1
2
3
4
5
6
7
name: notify-new-posts-telegram

on:
  push:
    branches:
      - main
  workflow_dispatch:

To get the files that have changed I use tj-actions/changed-files which is one of the most useful pieces of software I have ever found.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout main branch
        uses: actions/checkout@v4
        with:
          ref: main
          submodules: true
          fetch-depth: 0

      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v44
        with:
          safe_output: false
          # "New posts" are markdown files like content/posts/YYYY/MM/name/index.md
          files: content/posts/**/**/**/index.md
          # Find all files that have changed since the last remote commit on the target branch.
          since_last_remote_commit: true
          output_renamed_files_as_deleted_and_added: true

With the list of files that have changed we can execute the script.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
      - name: Install Python
        if: steps.changed-files.outputs.added_files_count > 0
        uses: actions/setup-python@v4
        with:
          python-version: "3.10.12"

      - name: Install Python dependencies
        if: steps.changed-files.outputs.added_files_count > 0
        run: |
          python -m pip install --upgrade pip
          python -m pip install fire
          python -m pip install python-frontmatter
          python -m pip install telethon          

      - name: Notify of new posts
        if: steps.changed-files.outputs.added_files_count > 0
        env:
          ALL_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }}
          API_ID: ${{ secrets.API_ID }}
          API_HASH: ${{ secrets.API_HASH }}
          BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
          CHANNEL: ${{ secrets.CHANNEL }}
        run: >
          for file in $ALL_ADDED_FILES;
          do python notify_telegram.py run --filepath $file;
          done          

For this to work we need to make sure of having stored the API_ID, API_HASH, BOT_TOKEN and CHANNEL values in the secrets of the repository. Here’s a handy guide for using secrets in GitHub Actions.

Putting everything together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
name: notify-new-posts-telegram

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout main branch
        uses: actions/checkout@v4
        with:
          ref: main
          submodules: true
          fetch-depth: 0

      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v44
        with:
          safe_output: false
          # "New posts" are markdown files like content/posts/YYYY/MM/name/index.md
          files: content/posts/**/**/**/index.md
          # Find all files that have changed since the last remote commit on the target branch.
          since_last_remote_commit: true
          output_renamed_files_as_deleted_and_added: true

      - name: Install Python
        if: steps.changed-files.outputs.added_files_count > 0
        uses: actions/setup-python@v4
        with:
          python-version: "3.10.12"

      - name: Install Python dependencies
        if: steps.changed-files.outputs.added_files_count > 0
        run: |
          python -m pip install --upgrade pip
          python -m pip install fire
          python -m pip install python-frontmatter
          python -m pip install telethon          

      - name: Notify of new posts
        if: steps.changed-files.outputs.added_files_count > 0
        env:
          ALL_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }}
          API_ID: ${{ secrets.API_ID }}
          API_HASH: ${{ secrets.API_HASH }}
          BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
          CHANNEL: ${{ secrets.CHANNEL }}
        run: >
          for file in $ALL_ADDED_FILES;
          do python notify_telegram.py run --filepath $file;
          done          

That’s it. We have made a Python script that sends messages through a Telegram bot and a GitHub action to trigger that script each time new content arrives to the repository. You can see this set up in action in the source code of this website.

Join the channel

If you have come this far, you might want to join my channel to keep up to date with new content. I write about my journey on the programming field and other computer-ish stuff. Thanks for reading.