Writing a telegram bot in Clojure

June 29 2017

goal

You would like to create a telegram bot, that can be invited to channels and chats and can search and answer something for you … This post is for you.

The plan of today is to get through all the steps to get this working properly by yourself.

This bot will be done using a webhook, which is a little bit harder to setup than regular polling due to its use of https, so yes this will be covered as well.

Ask the telegram botfather to create a new bot for you.

This is done from telegram itself. And those are the steps:

  • send a message to botfather @BotFather
  • check help with /help
  • create new bot with /newbot

Give it a name, should end in bot, so you can

Name: fruits_ai_bot

Token: 404849973:AAGuyH5NjKh05t-9FqEPx_2UlfHe2O2lvds

URL: t.me/fruits_ai_bot

Your bot can now accept payments for goods and services via Telegram. :)

set up the bot to use a certificate

The original morse library does not support setting self-signed certificates to telegram, so we will do that using curl. Basically a simple http request to the telegram api of your bot, to let it know your custom certificate when doing the SSL handshake. Token is the token received when you have setup your own bot with botfather.

curl -F "url=https://hellonico.tokyo/handler" \ 
 -F "certificate=@/home/niko/timebot/ssl/YOURPUBLIC.pem" \ 
 https://api.telegram.org/bot<token>/setWebhook

bot settings: join groups and settings

Two more settings need to be done with the botfather, so your bot is available for groups, and can also read messages.

First the join group settings:

Second the privacy settings:

Create a new Clojure project template with Leiningen

Leiningen will be used to generate the clojure project template:

lein new timebot

The rest is fun coding…

update the project.clj file

Your bot will be using a set of common and not-so common Clojure libraries. In the original example, the morse library was used for handlers, but it does not

(defproject timebot "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :main timebot.core
  :profiles {:uberjar {:aot :all}}
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :repositories {"hellonico"  {:sign-releases false :url "http://hellonico.tokyo:8081/repository/hellonico/"}}
  :dependencies [
    [org.clojure/clojure "1.8.0"]
    [org.hellonico/morse "0.2.5"]
    [org.hellonico/jakaroma "0.2"]
    [compojure "1.6.0"]
    [ring/ring-json "0.4.0"]
    [http-kit "2.2.0"]
    [javax.servlet/servlet-api "2.5"] ])

write some clojure code using morse

The bot will simply talk about the current time, when a message is sent to him, either from a direct chat, or from a channel; indeed the bot can also be added to a channel and read channel messages with the settings you have done before.

Settings are kept in a separate file, settings.clj, so you can customize important details outside the app.

{
  :token "400083184:AAH6njcJv9iZtRnaE-Kv9-hCus2XSh0eROA"
  :date-format "MM-dd-yyyy (HH:mm:ss)"
  :not-found-message "Not Found"
  :starting-message "Starting time bot ..."
  :port "9090"
}   

Those settings will be slurped after from the main Clojure namespace. Simple sending of text can be see using the morse library, where the token is your bot’s token, and the chatid can be retrieved from the message, (or some previous chats…)

 (t/send-text 
          token 
          chatid 
          "some text")

Routing is done through some simple compojure routes, where mostly only the body of the request is of interest in this example:

(defroutes app-routes
  (POST "/debug" {body :body} (clojure.pprint/pprint body))
  (POST "/handler" {body :body} (timebot-api body))
  (route/not-found (settings :not-found-message)))

For convenience json messages are wrapped for you to clojure key-ified collections using wrap-json-body:

(def app
  (-> (handler/site app-routes)
      (wrap-json-body {:keywords? true})))

The full Clojure code listing is shown below:

(ns timebot.core
  (:use [ring.adapter.jetty])
  (:import [fr.free.nrw.jakaroma Jakaroma])
  (:require 
    [cheshire.core :refer :all]
    [ring.middleware.json :refer [wrap-json-body]]
    [compojure.core     :refer :all]
    [compojure.route    :as route]
    [compojure.handler  :only [site] :as handler] 
    [org.httpkit.server :as httpkit]
    [morse.api          :as t])
  (:gen-class))

(def settings 
  (read-string (slurp "settings.clj")))
(def token 
  (settings :token))

(defn what-time-is-it-now[]
  (.format 
  (java.text.SimpleDateFormat. (settings :date-format))
  (java.util.Date.)))

(def not-nil? (complement nil?))

(defn handle-channel-post[channel_post]
  (let [chatid (-> channel_post :chat :id) ]
      (t/send-text 
          token 
          chatid 
          (what-time-is-it-now))))
(defn handle-message[message]
   (let [chatid (-> message :chat :id) ]
      (t/send-text 
          token 
          chatid 
          (what-time-is-it-now))))

(defn timebot-api [ {message :message, channel_post :channel_post, :as all}]
  (println (generate-string all))
  (if (not-nil? message)
    (handle-message message))
  (if (not-nil? channel_post)
    (handle-channel-post channel_post))
  {:status 200 :headers {}})

(defroutes app-routes
  (POST "/debug" {body :body} (clojure.pprint/pprint body))
  (POST "/handler" {body :body} (timebot-api body))
  (route/not-found (settings :not-found-message)))

(def app
  (-> (handler/site app-routes)
      (wrap-json-body {:keywords? true})))

(defn -main[& args]
  (clojure.pprint/pprint settings)
  (httpkit/run-server (handler/site #'app) {:port (Integer/parseInt (settings :port))})
  (println (settings :starting-message))
  (Thread/sleep Long/MAX_VALUE))

run the bot

Running the bot, can be done with the run command of leiningen:

lein run

or obviously through the uberjar path.

lein uberjar
java -jar target/timebot-standalone*.jar 

telegram message format

Message coming to a private chat have the json format below, the top interesting element is message

{:update_id 571443652,
 :message
 {:message_id 87,
  :from
  {:id 121843071,
   :first_name "Nico",
   :last_name "Nico",
   :username "hellonico",
   :language_code "en"},
  :chat
  {:id 121843071,
   :first_name "Nico",
   :last_name "Nico",
   :username "hellonico",
   :type "private"},
  :date 1498630563,
  :text "hello"}}

While message coming through a channel are slightly different, with the top interesting element being channel_post

{:update_id 571443636,
 :channel_post
 {:message_id 13,
  :from
  {:id 121843071,
   :first_name "Nico",
   :last_name "Nico",
   :username "hellonico",
   :language_code "en"},
  :chat {:id -1001064008907, :title "hello", :type "channel"},
  :date 1498628307,
  :text "oui bonjour"}}

A mention of the bot’s name in the message has one more extra field:

{:update_id 571443635,
 :channel_post
 {:message_id 12,
  :from
  {:id 121843071,
   :first_name "Nico",
   :last_name "Nico",
   :username "hellonico",
   :language_code "en"},
  :chat {:id -1001064008907, :title "hello", :type "channel"},
  :date 1498628258,
  :text "@myowntime_bot hello super news",
  :entities [{:type "mention", :offset 0, :length 14}]}}

replay messages with curl

Messages can be replayed using standard curl on your https handler. Note the usage of the curl parameter -k so that curl acknowledge your self-signed certificate. You can of course remove it if you have real ones.

echo '{"update_id":571443653,"message":{"message_id":89,"from":{"id":121843071,"first_name":"Nico","last_name":"Nico","username":"hellonico","language_code":"en"},"chat":{"id":121843071,"first_name":"Nico","last_name":"Nico","username":"hellonico","type":"private"},"date":1498630765,"text":"hello"}}' | curl -k -H  "Content-Type: application/json" -X POST "https://hellonico.tokyo/handler" -T -

Built with Hugo

© Nicolas Modrzyk 2019 - hellonico @ gmail dot com