org.clojars.hsestupin/slark

0.0.5


Monitor your Telegram bot activity with ease

dependencies

org.clojure/clojure
1.8.0
environ
1.0.3
clj-http
3.1.0
org.clojure/data.json
0.2.6
com.taoensso/timbre
4.4.0
org.clojure/core.async
0.2.385



(this space intentionally left almost blank)
 
(ns slark.telegram
  (:require [clj-http.client :as http]
            [clojure.data.json :as json]
            [clojure.java.io :as io]
            [clojure.string :as str]
            [environ.core :refer :all]))

API basics

All public API methods such as get-updates, send-photo, etc accepts a parameter called options. Basically it contains particular method's optional arguments according to Telegram API. But there are some additional options provided by Slark (all of the API methods support them):

  • :token - bot token. Default value is taken via Environ library by key :telegram-bot-token
  • :entire-response? - if you want to get entire http response but not only telegram useful payload part. Defaults to false.
  • :http-options - http options which is passed to clj-http library. You can specify in this map any options which is supported by clj-http
  • :dry-run? - if this options is enabled then no http requests are executed. Instead information about request is returned
(comment
  (send-message 1234 "Hello world"
                {:token "1234:ABCD"
                 :dry-run? true
                 :entire-response? false
                 :http-options {:retry-handler (fn [ex try-count http-context]
                                                 (println "Got:" ex)
                                                 (if (> try-count 4) false true))}})

  ;; returned value would contain just request information
  {:throw-exceptions? false,
   :accept :json,
   :query-params {"chat_id" 1234, "text" "Hello world"},
   :retry-handler "#function[slark.api/eval23020/fn--23021]",
   :method :get,
   :url "https://api.telegram.org/bot1234:ABCD/sendMessage"}
  )
(def base-url "https://api.telegram.org/bot")
(def default-post-optional-keys #{:disable-notification
                                  :reply-to-message-id
                                  :reply-markup})
(defn get-token []
  (env :telegram-bot-token))
(defn- to-telegram-format
  [k]
  (-> k
      name
      (str/replace "-" "_")))

Recursively transforms all map keys to telegram format replacing '-' with '_'

(defn- to-telegram-format-keys
  [m]
  (let [to-telegram-format (fn [[k v]]
                             [(to-telegram-format k)
                              v])]
    (clojure.walk/postwalk (fn [x]
                             (if (map? x)
                               (into {} (map to-telegram-format x))
                               x))
                           m)))
(defn- build-telegram-api-url
  [{:keys [token] :or {token (get-token)}} url-suffix]
  {:pre [(some? token)]}
  (str base-url token url-suffix))
(defn extract-telegram-payload
  [response]
  (-> response
      :body
      ;; clojurify response keys. Write :chat-id instead of chat_id 
      (json/read-str :key-fn #(keyword (str/replace % "_" "-")))))
(defn- get-result
  [entire-response? response]
  (if entire-response?
    response
    (extract-telegram-payload response)))

Do a HTTP GET request to a telegram bot API with specified url-suffix and query-params

(defn- do-get-request
  [{:keys [http-options entire-response? dry-run?] :as options} query-params url-suffix]
  (let [telegram-api-url (build-telegram-api-url options url-suffix) 
        request-options (merge {:throw-exceptions? false
                                   :accept :json
                                   :query-params (to-telegram-format-keys query-params)}
                               http-options)]
    (if dry-run?
      (merge request-options {:method :get :url telegram-api-url})
      (get-result entire-response? (http/get telegram-api-url request-options)))))
(defn- merge-multipart
  [required-data optional-params]
  (reduce (fn [data [k v]]
            (conj data {:name k :content (str v)}))
          required-data
          optional-params))

Do a HTTP POST multipart/form-data request to a telegram bot API with specified url-suffix and multipart-data

(defn- do-post-request
  ([options url-suffix required-data]
   (do-post-request options url-suffix required-data default-post-optional-keys))
  ([{:keys [http-options entire-response? dry-run?] :as options} url-suffix required-data optional-keys]
   (let [optional-data (to-telegram-format-keys (select-keys options optional-keys))
         multipart-data (merge-multipart required-data optional-data)
         telegram-api-url (build-telegram-api-url options url-suffix) 
         request-options (merge {:throw-exceptions? false
                                     :accept :json
                                     :multipart multipart-data}
                                    http-options)]
     (if dry-run?
       (merge request-options {:method :post :url telegram-api-url})
       (get-result entire-response? (http/post telegram-api-url request-options))))))

Receive updates from telegram Bot via long-polling. For more info look at official doc.

Supplied options:

  • :offset - Identifier of the first update to be returned
  • :limit - Limits the number of updates to be retrieved. Values between 1—100 are accepted. Defaults to 100
  • :timeout - Timeout in seconds for long polling. Defaults to 0, i.e. usual short polling
(defn get-updates
  [& [options]]
  (let [query-params (merge {:timeout 1
                             :offset  0
                             :limit   100}
                            options)]
    (do-get-request options query-params "/getUpdates")))

Send setWebhook request to telegram API. For more detailed information look at official doc. How to generate self-signed certificate - https://core.telegram.org/bots/self-signed

Supplied options:

  • :url - HTTPS url to send updates to. Use an empty string to remove webhook integration. Defaults to empty string
  • :certificate - certificate file. One of the following types - InputStream, File, a byte-array, or an instance of org.apache.http.entity.mime.content.ContentBody
(defn set-webhook
  [& [{:keys [url certificate] :or {url ""} :as options}]]
  (let [partial-data [{:name "url" :content url}]
        multipart-data (if certificate
                         (conj partial-data {:name  "certificate" :content certificate})
                         partial-data)]
    (do-post-request options "/setWebhook" multipart-data [])))

A simple method for testing your bot's auth token. Returns basic information about the bot in form of a User object. official doc

(defn get-me
  [& [options]]
  (do-get-request options {} "/getMe"))

Use this method to send text messages. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. text - Text of the message to be sent
(defn send-message
  [chat-id text & [options]]
  (let [optional-params (select-keys options [:parse-mode
                                              :disable-web-page-preview
                                              :disable-notification
                                              :reply-to-message-id
                                              :reply-markup])
        query-params (merge {:chat-id chat-id :text text} optional-params)]
    (do-get-request options query-params "/sendMessage")))

Use this method to forward messages of any kind. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. from-chat-id - Unique identifier for the chat where the original message was sent (or channel username in the format @channelusername)
  3. message-id - Unique message identifier
(defn forward-message
  [chat-id from-chat-id message-id & [options]]
  (let [optional-params (select-keys options [:disable-notification])
        query-params (merge {:chat-id chat-id
                             :from-chat-id from-chat-id
                             :message-id message-id} optional-params)]
    (do-get-request options query-params "/forwardMessage")))

Use this method to send photos. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. photo - Photo to send. One of the following types - InputStream, File, a byte-array, or an instance of org.apache.http.entity.mime.content.ContentBody
(defn send-photo
  [chat-id photo & [options]]
  {:pre [(some? chat-id) (some? photo)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "photo" :content photo}]]
    (do-post-request options "/sendPhoto" required-data
                     (conj default-post-optional-keys :caption))))

Use this method to send audio files, if you want Telegram clients to display them in the music player. Your audio must be in the .mp3 format. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. audio - Audio file to send. One of the following types - InputStream, File, a byte-array, or an instance of org.apache.http.entity.mime.content.ContentBody
(defn send-audio
  [chat-id audio & [options]]
  {:pre [(some? chat-id) (some? audio)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "audio" :content audio}]]
    (do-post-request options "/sendAudio" required-data (conj default-post-optional-keys
                                                              :duration
                                                              :performer
                                                              :title))))

Use this method to send general files. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. document - File to send. One of the following types - InputStream, File, a byte-array, or an instance of org.apache.http.entity.mime.content.ContentBody
(defn send-document
  [chat-id document & [options]]
  {:pre [(some? chat-id) (some? document)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "document" :content document}]]
    (do-post-request options "/sendDocument" required-data
                     (conj default-post-optional-keys :caption))))

Use this method to send .webp stickers. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. sticker - Sticker to send. One of the following types - InputStream, File, a byte-array, or an instance of org.apache.http.entity.mime.content.ContentBody
(defn send-sticker
  [chat-id sticker & [options]]
  {:pre [(some? chat-id) (some? sticker)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "sticker" :content sticker}]]
    (do-post-request options "/sendSticker" required-data)))

Use this method to send video files, Telegram clients support mp4 videos (other formats may be sent as Document). On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. video - Video to send. One of the following types - InputStream, File, a byte-array, or an instance of org.apache.http.entity.mime.content.ContentBody
(defn send-video
  [chat-id video & [options]]
  {:pre [(some? chat-id) (some? video)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "video" :content video}]]
    (do-post-request options "/sendVideo" required-data (conj default-post-optional-keys
                                                              :duration
                                                              :width
                                                              :height
                                                              :caption))))

Use this method to send audio files, if you want Telegram clients to display the file as a playable voice message. For this to work, your audio must be in an .ogg file encoded with OPUS (other formats may be sent as Audio or Document). On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. voice - Audio file to send. One of the following types - InputStream, File, a byte-array, or an instance of org.apache.http.entity.mime.content.ContentBody
(defn send-voice
  [chat-id voice & [options]]
  {:pre [(some? chat-id) (some? voice)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "voice" :content voice}]]
    (do-post-request options "/sendVoice" required-data (conj default-post-optional-keys
                                                              :duration))))

Use this method to send point on the map. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. latitude - Latitude of location. Float number
  3. longitude - Longitude of location. Float number
(defn send-location
  [chat-id latitude longitude & [options]]
  {:pre [(some? chat-id) (some? latitude) (some? longitude)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "latitude" :content (str latitude)}
                       {:name "longitude" :content (str longitude)}]]
    (do-post-request options "/sendLocation" required-data)))

Use this method to send information about a venue. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. latitude - Latitude of location. Float number
  3. longitude - Longitude of location. Float number
  4. title - Name of the venue
  5. address - Address of the venue
(defn send-venue
  [chat-id latitude longitude title address & [options]]
  {:pre [(some? chat-id)
         (some? latitude)
         (some? longitude)
         (some? title)
         (some? address)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "latitude" :content (str latitude)}
                       {:name "longitude" :content (str longitude)}
                       {:name "title" :content (str title)}
                       {:name "address" :content (str address)}]]
    (do-post-request options "/sendVenue" required-data (conj default-post-optional-keys
                                                              :foursquare-id))))

Use this method to send phone contacts. On success, the sent Message is returned. official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. phone-number - Contact's phone number
  3. first-name - Contact's first name
(defn send-contact
  [chat-id phone-number first-name & [options]]
  {:pre [(some? chat-id)
         (some? phone-number)
         (some? first-name)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "phone_number" :content (str phone-number)}
                       {:name "first_name" :content (str first-name)}]]
    (do-post-request options "/sendContact" required-data (conj default-post-optional-keys
                                                              :last-name))))

Use this method when you need to tell the user that something is happening on the bot's side. The status is set for 5 seconds or less (when a message arrives from your bot, Telegram clients clear its typing status). official doc

  1. chat-id - Unique identifier for the target chat or username of the target channel (in the format @channelusername)
  2. action - Type of action to broadcast. Choose one, depending on what the user is about to receive: typing for text messages, uploadphoto for photos, recordvideo or uploadvideo for videos, recordaudio or uploadaudio for audio files, uploaddocument for general files, find_location for location data. You can pass this argument as a clojure keyword like :upload-photo
(defn send-chat-action
  [chat-id action & [options]]
  {:pre [(some? chat-id)
         (some? action)]}
  (let [required-data [{:name "chat_id" :content (str chat-id)}
                       {:name "action" :content (if (keyword? action)
                                                  (to-telegram-format action)
                                                  (str action))}]]
    (do-post-request options "/sendChatAction" required-data {})))

For debugging purposes

(comment
  (do
    (def chat-id (Integer/parseInt (env :chat-id)))))
 
(ns slark
  (:require [clojure.string :as str]
            [environ.core :refer :all]
            [slark.telegram :refer :all]
            [taoensso.timbre :as timbre
             :refer (log  trace  debug  info  warn  error  fatal  report
                          logf tracef debugf infof warnf errorf fatalf reportf
                          spy get-env log-env)]
            [clojure.core.async :as async :refer
             (close! put! poll! go go-loop chan <! <!! >! >!! alt! alts! alt!! alts!!)]))

True if this update represents a bot command. Otherwise false

(defn bot-command?
  [{:keys [entities]}]
  (not-empty (filterv  #(= (:type %) "bot_command") entities)))

Returns corresponding handler to message if it's a bot like message which looks like /hi .... Otherwise returns nil.

(defn get-command
  [{:keys [text] :as message}]
  (when (bot-command? message)
    (first (.split (.substring text 1) " "))))
(defn get-message
  [update]
  (or (:message update) (:edited-message update)))
(defn handle-update [handlers update state]
  (let [message (get-message update)
        command (get-command message)
        handler (handlers command)]
    (if handler
      (do
        (debug "Update" (:update-id update) "will be handled by" command)
        (handler update state))
      state)))

Async execute get-udpates function because it might utilize blocking IO.

(defn- get-updates-async
  [get-updates-fn offset]
  (let [result-ch (chan 0)]
    (go (>! result-ch (get-updates-fn offset)))
    result-ch))

Puts telegram updates obtained via :get-updates-fn into the supplied channel with >!. Also returns a function which will terminate go-loop when called.

Supplied :get-updates-fn is a 1 argument function which is called with current update offset. By default it just delegates getting updates to (get-updates {:offset offset}). :get-updates-fn will not be called unless go-loop is parking trying to push update to channel with >!

Also there are additional optional arguments: :initial-offset - first offset to begin getting updates with.

By default the supplied channel will not be closed after telegram error or termination request, but it can be determined by the :close? parameter.

(defn updates-onto-chan
  [ch & [{:keys [initial-offset close? get-updates-fn]
          :or {initial-offset 0
               close? false
               get-updates-fn (fn [offset]
                                (get-updates {:offset offset}))}
          :as opts}]]
  (let [terminate-ch (chan)
        close-ch-fn (fn [] (when close?
                             (close! ch)))]
    (go-loop [offset initial-offset]
      (debug "Trying to get-udpates with offset" offset)
      (let [get-updates-ch (get-updates-async get-updates-fn offset)]
        (alt!
          terminate-ch
          (do
            (warn "Termination request") (close-ch-fn))
          get-updates-ch
          ([{:keys [ok result] :as response}]
           (alt!
             [[ch response]]
             (if ok
               (recur (reduce max offset
                              (mapv (comp inc :update-id) result)))
               (do
                 (warn "Telegram error" response)
                 (close-ch-fn)))
             terminate-ch
             (do
               (warn "Termination request") (close-ch-fn)))))))
    (fn terminate! []
      (put! terminate-ch :terminate))))

Creates transducer which handles supplied command updates. Second argument is a function which takes an Update map.

(defn command-handling
  [command handler]
  (fn [rf]
    (fn
      ([] (rf))
      ([result] (rf result))
      ([result update]
       (let [message (get-message update)]
         (when (and (bot-command? message)
                    (= command (get-command message)))
           (handler update))
         (rf result update))))))
(comment
  (do
    (defn- echo
      [update]
      (let [message (get-message update)
            chat-id (get-in message [:chat :id])
            text (:text message)]
        (send-message chat-id (str "received '" text "'"))))
    (def c (chan 1 (comp
                    (filter (comp not-empty :result))
                    (map #(do (println %) (:result %)))
                    cat
                    (command-handling "echo" echo))))
    (def terminate (updates-onto-chan c))
    (go-loop [update (<! c)]
      (when update
        (do (info "Update" (:update-id update) "processed")
            (recur (<! c)))))))