Tag Cloud
modbus kepware c-programming IoT golang telegram bot python cli urwid elasticsearch aws ecs apache spark scala AWS EMR hadoop webhooks ssl nginx digital-ocean emr apache pig datapipeline found.io elastic-cloud rails try capybara docker devops capistrano heka bigquery kafka protobuf vim iterm javascript emberjs git scripting dnsmasq bem frontend meteorjs meteorite heroku

How I Built a Simple Telegram Bot in Go

This is the first part of the Golang Telegram Bot series. In this series, I’ll show you, with code samples, how I built a Golang Telegram Bot for my own use. It would listen in and respond in real-time to certain text cues. Finally I’ll also show you how to get a self-signed SSL cert working with Nginx and deploying the application in a Docker container on a Digital Ocean instance.

Hopefully this will help anyone out there who would like to try their hand at their own bot on Telegram! The code for this bot is currently hosted at https://github.com/aranair/remindbot if you’ll like to just skip to the code immediately.

Backstory

I’ve been using Telegram for a really long time, and been wanting to build a Telegram bot for a long time since they first announced it.

Initially, I was thrown off a little by the requirement of https for the webhooks, thinking that I might need a domain and a SSL cert to get it working but I quickly found out that a self-signed SSL certificate would work just as well in this scenario!

So, if you find yourself in the same situation, don’t worry about it! It might be slightly more complex to set up a self-signed SSL cert with Nginx, but it’s not that difficult! In this series, I’ll show you the code samples that got my own bot up and running in production!

Creating a Bot and Getting an API Key

First, I sent /newbot to this guy.

botfather.png

After creating the bot, I got a set of (botId and API key) by sending him a /token command.
The credentials are needed for subsequent requests to execute methods using the Telegram API.

# {botId}:{apiKey}
123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11

Take note of the word bot before the <token>!

https://api.telegram.org/bot<token>/METHOD_NAME

There are a ton of Api methods listed over at the Telegram Bot docs but for the purpose of this simple starter bot, I will only be using setWebhook and sendMessage.

Webhook vs Polling

Great! I have the API key now. Next, I have to choose between the two ways to get messages from Telegram.

  • Webhooks via setWebhook or
  • Polling via getUpdates

I’m fairly certain that it is easier to set up with getUpdates but polling isn’t always an option and not having real-time updates isn’t as fun IMHO :P So, for this bot, I went with webhooks as I wanted the bot to respond in real-time.

With webhooks, everytime there is a message (when privacy mode is disabled anyway), the API endpoint will be sent a message. So the main objective, is simply, to parse each of these updates and respond appropriately.

To set up the Webhook all I had to do is to send a curl request to the Telegram Api.

curl -F "url=https://your.domain.com" -F "certificate=@/file/path/ssl/bot.pem" https://api.telegram.org/bot12345:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/setWebhook

Of course, before that, I need the self-signed SSL public pem file; that is sent as an InputFile so that Telegram can differentiate the correct server it’s supposed to send all the messages to. This part is a bit more relevant in the second part of the series where I deploy the bot to Digital Ocean so I’ll leave this explanation to the second part.

Router

I had a choice of many popular router implementations out there like gin and gorilla. But for this project, I chose to go a bit ligher with just github.com/gorilla/context and github.com/julienschmidt/httprouter since I don’t really need that much functionality.

Ok, to be fair, even the context (for the params) isn’t really needed at this point, but since I would need them for get requests in future, I’ve set it all up first.

package router

import (
    "net/http"

    "github.com/gorilla/context"
    "github.com/julienschmidt/httprouter"
)

type router struct {
    *httprouter.Router
}

func New() *router {
    return &router{httprouter.New()}
}

func (r *router) POST(path string, h http.Handler) {
    r.Handle("POST", path, wrapHandler(h))
}

func wrapHandler(h http.Handler) httprouter.Handle {
    return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
        context.Set(r, "params", ps)
        h.ServeHTTP(w, r)
    }
}

Configs

To parse the config toml file, I used github.com/BurntSushi/toml with toml files. It’s like yml on steroids lol.

The datapath is actually the data volume path for Docker; but I’ll talk about that in more details in the second part of this series about deployments.

Sample configs.toml

[bot]
  bot_id = "YOUR_BOT_ID"
  api_key = "YOUR_API_KEY"
[database]
  datapath = "PATH_TO_SQLITEE_FOLDER"

App Context / Http Handlers Glue

Instead of a global object, I mixed app-wide objects like configs and the DB object into an AppContext. To link the AppContext and the http handlers together, I used github.com/justinas/alice as the glue.

App Context:

type AppContext struct {
    db   *sql.DB
    conf config.Config
    cmds commands.Commands
}

func NewAppContext(db *sql.DB, conf config.Config, cmds commands.Commands) AppContext {
    return AppContext{db: db, conf: conf, cmds: cmds}
}

Message Receiver:

func (ac *AppContext) CommandHandler(w http.ResponseWriter, r *http.Request) { ... }

Main

Since this bot was mainly built for personal reminders, I’ve chosen to go with Sqlite3 for now but I’ve got it set up with pq before and it is fairly easy to swap it out, since both the libraries uses the database/sql library.

_, err := toml.DecodeFile("configs.toml", &conf)
db, err := sql.Open("sqlite3", conf.DB.Datapath+"/reminders.db")
checkErr(err)

ac := handlers.NewAppContext(db, conf, commands.NewCommandList())

stack := alice.New()
r := router.New()
r.POST("/reminders", stack.ThenFunc(ac.CommandHandler))

http.ListenAndServe(":8080", r)

Parsing the Updates

The updates that Telegram sends to the bot contains a lot of fields, including some optional ones that may or may not appear depending on the type of update, but the ones I’m concerned with for this bot are only these:

type Update struct {
    Id  int64   `json:"update_id"`
    Msg Message `json:"message"`
}

type Message struct {
    Id   int64  `json:"message_id"`
    Text string `json:"text"`
    Chat Chat   `json:"chat"`
    User User   `json:"from"` # Note: this is an optional field so it may be empty
}

type Chat struct {
    Id    int64  `json:"id"`
    Title string `json:"title"`
}

type User struct {
    Id        int64  `json:"id"`
    FirstName string `json:"first_name"`
    Username  string `json:"username"` # Note: another optional field
}

Updates comes in as JSON and you can use the code snippet below with the structs above to decode it into a more usable object.

func (ac *AppContext) CommandHandler(w http.ResponseWriter, r *http.Request) {
    var update Update

    decoder := json.NewDecoder(r.Body)
    if err := decoder.Decode(&update); err != nil {
        log.Println(err)
    } else {
        log.Println(update.Msg.Text)
    }

    cmd, txt := ac.cmds.Extract(update.Msg.Text)
    chatId := update.Msg.Chat.Id

    switch s.ToLower(cmd) {
      ...
    }
}

Some key things to note here:

  • The message is parsed into update.Msg.Text
  • The chatId is in update.Msg.Chat.Id. This is important because you’ll need it to send a response back.
  • The bot currently doesn’t use User but I’ve written the code above so that you can get it as well.

Command Extraction

There is a Commands object that contains all the regexp.Regexp items that are used to find matches for commands. These are instantiated once during bot startup but I admit this part is a lot more repetitive than needed and I am still looking for ways to clean this up.

So if you have any suggestions, do let me know in the comments below!

package commands

import "regexp"

type Commands struct {
    rmt   *regexp.Regexp
    r     *regexp.Regexp
    l     *regexp.Regexp
    c     *regexp.Regexp
    cl    *regexp.Regexp
    hazel *regexp.Regexp
}

func NewCommandList() Commands {
    return Commands{
        rmt:   compileRegexp(`(?i)^(remind) me to (.+)`),
        r:     compileRegexp(`(?i)^(remind) (.+)`),
        l:     compileRegexp(`(?i)^(list)`),
        c:     compileRegexp(`(?i)^(clear) (\d+)`),
        cl:    compileRegexp(`(?i)^(clearall)`),
        hazel: compileRegexp(`(?i)(hazel)`),
    }
}

func compileRegexp(s string) *regexp.Regexp {
    r, _ := regexp.Compile(s)
    return r
}

func (c *Commands) Extract(t string) (string, string) {
    var a []string

    a = c.rmt.FindStringSubmatch(t)
    if len(a) == 3 {
        return a[1], a[2]
    }

    ...

    return "", ""
}

A couple of comments for the code above:

  • The (?i) is there for case-insensitive regexp.
  • I parse the commands and return the command and messages separately back to the route handler for it to do more there.
  • If it doesn’t match, it’ll just return empty strings and subsequently gets thrown away.

Sending a Response

I can send either a GET or a POST request to the appropriate API. I used the sendMessage method via the API. The text in this case, can contain codes like \n and Unicode like 안녕.

To replace the botId, apiKey, chatId and text for the GET request, I do the following:

func (ac *AppContext) sendText(chatId int64, text string) {
    link := "https://api.telegram.org/bot{botId}:{apiKey}/sendMessage?chat_id={chatId}&text={text}"
    link = s.Replace(link, "{botId}", ac.conf.BOT.BotId, -1)
    link = s.Replace(link, "{apiKey}", ac.conf.BOT.ApiKey, -1)
    link = s.Replace(link, "{chatId}", strconv.FormatInt(chatId, 10), -1)
    link = s.Replace(link, "{text}", url.QueryEscape(text), -1)

    _, _ = http.Get(link)
}

And it’s done! The application code is pretty short I’ll say.

To Be Continued

I hope this gives you a rough idea if you would like to get started in writing the application code for a Telegram Bot.

In Part 2, I will talk about how I set up Docker for the bot, and also the self-signed SSL cert with Nginx as the reverse proxy on a Digital Ocean instance. Finally, I’ll also show how I set up the git webhooks so that I can deploy with just one command!

comments powered by Disqus