;; Filename: src/freeswitch_clj/protocol.clj
  Author :   <  >
  Copyright : 2021   <  >
;;
  This work is distributed under MIT Public License .
;; Please see the attached LICENSE file in project root.
(ns ^{:doc "Functions to encode & decode text based protocol of freeswitch ESL."
      :author "Titon Barua"
      ;; Hide this namespace from public doc in cljdoc.org website.
      :no-doc true}
 freeswitch-clj.protocol
  (:require [clojure.string :as str]
            [clojure.xml :as xml]
            [clojure.set :refer [rename-keys]]

            [jsonista.core :as json ]))

(def ^:private lineend "\n")
(def ^:private double-lineend "\n\n")
(def ^:private re-double-lineend #"\n\n")

(defn url-decode
  "Decode only percent encoded chars from a string."
  [text]
  (str/replace text
               #"%([0-9A-Fa-f]{2})"
               (fn [[_ hexed-char]]
                 (str (char (Integer/parseInt hexed-char 16))))))

(defn- absorb-newlines
  "Replace consecutive newlines (and surrounding whitespace chars)
   in a string with a single space."
  [s]
  (str/replace s #"\s*[\r\n]+\s*" " "))

(defn encode-headers
  "Encode a header-map for outgoing message."
  [hdrs]
  (->> hdrs
       (map (fn [[k v]]
              (str (name k)
                   ": "
                   (-> v (str) (str/trim) (absorb-newlines)))))
       (sort-by (fn [[k v]] k))
       (str/join lineend)))

(defn encode
  "Encode an outgoing message."
  [cmd-line cmd-hdrs ^String cmd-body]
  (str (->> cmd-line
            (map str/trim)
            (str/join " ")
            (absorb-newlines))

       ;; Add headers.
       (if (empty? cmd-hdrs)
         ""
         (str lineend
              (encode-headers cmd-hdrs)))

       ;; Add a content-length header, if body is present.
       (if (empty? cmd-body)
         ""
         (str lineend
              "Content-Length: "
              (count (.getBytes cmd-body))))

       ;; Add message completion indicator.
       double-lineend

       ;; If present, add body.
       (if (empty? cmd-body) "" cmd-body)))

(defn decode-envelope-headers
  "Decode an envelope-header section from incoming message into a map."
  [data]
  (->> data
       str/split-lines
       (map #(str/split % #"\s*:\s*" 2))
       (map (fn [[k v]] [(keyword (str/lower-case k)) v]))
       (into {})))

(defn decode-headers
  "Decode a header section from incoming message into a map."
  [data]
  (->> data
       (decode-envelope-headers)
       (map (fn [[k v]] [k (url-decode v)]))
       (into {})))

(defn decode
  "Extract first message from the data. Returns both the decoded
message and rest of the data in a vector. If no message could be
decoded, message is nil."
  [data]
  (if-not (str/includes? data double-lineend)
    ;; Can't find a message terminator in the buffer.
    [nil data]

    (let [[hdrs data-rest] (str/split data re-double-lineend 2)
          hdrs' (decode-envelope-headers hdrs)
          clen (get hdrs' :content-length)]
      (if-not clen
         ;; Message has nothing but envelope headers.
        [{:envelope-headers hdrs'
          :envelope-content nil}
         data-rest]

        (let [clen' (Integer/parseInt clen)]
          (if (< (count data-rest) clen')
             ;; Don't have the full content body. Need more bytes.
            [nil data]

  Parse content - body .
            (let [content (subs data-rest 0 clen')
                  data-rest' (subs data-rest clen')]
              [{:envelope-headers hdrs'
                :envelope-content content}
               data-rest'])))))))

(defn decode-all
  "Extract all messages from the given string data.
Also returns rest of the data."
  [data]
  (loop [msgs [], data data]
    (let [[m data-rest] (decode data)]
      (if m
        (recur (conj msgs m) data-rest)
        [msgs data-rest]))))

(defn parse-command-reply
  "Parse a 'command/reply' message."
  [{:keys [envelope-headers envelope-content] :as msg}]
  (assert (= (envelope-headers :content-type) "command/reply"))
  (let [reply-text (envelope-headers :reply-text)
        ok (str/starts-with? reply-text "+")]
    (-> envelope-headers
        (dissoc :content-type)
        (assoc :ok ok))))

(defn parse-api-response
  "Parse an 'api/response' message."
  [{:keys [envelope-headers envelope-content] :as msg}]
  (assert (= (envelope-headers :content-type) "api/response"))
  (let [ok (not (str/starts-with? envelope-content "-"))]
    (-> envelope-headers
        (dissoc :content-type)
        (dissoc :content-length)
        (assoc :ok ok)
        (assoc :result envelope-content))))

(defn parse-bgapi-response
  "Parse result from an event generated by a 'bgapi' request."
  [{:keys [body] :as event}]
  (let [ok (not (str/starts-with? body "-ERR"))]
    {:ok ok
     :result body
     :event event}))

(defmulti parse-event
  "Extract events from messages with content-type 'text/event-*'."
  (fn [{:keys [envelope-headers] :as msg}]
    (envelope-headers :content-type)))

(defmethod parse-event "text/event-plain"
  [{:keys [envelope-content] :as msg}]
  (let [[hdrs body] (str/split envelope-content re-double-lineend 2)
        hdrs' (decode-headers hdrs)
        clen (get hdrs' :content-length)]
    (if-not clen
      ;; Message does not have body.
      (assoc hdrs' :body nil)
      (do (assert (= (count body)
                     (Integer/parseInt (hdrs' :content-length))))
          (-> hdrs'
              (dissoc :content-length)
              (assoc :body body))))))

; A typical JSON event content:
  { " Event - Name":"BACKGROUND_JOB","Core - UUID":"6fa5901e-72ea-4399 - b689 - afe55a84a2c5","FreeSWITCH - Hostname":"oscar - delu","FreeSWITCH - Switchname":"oscar - delu","FreeSWITCH - IPv4":"192.168.250.70","FreeSWITCH - IPv6":"::1","Event - Date - Local":"2017 - 11 - 05 13:07:27","Event - Date - GMT":"Sun , 05 Nov 2017     - Timestamp":"1509865647430878","Event - Calling - File":"mod_event_socket.c","Event - Calling - Function":"api_exec","Event - Calling - Line - Number":"1557","Event - Sequence":"549","Job - UUID":"d4cd5087 - 130f-4df7 - 994d-885d77673d3d","Job - Command":"status","Content - Length":"327","_body":"UP 0 years , 0 days , 0 hours , 3 minutes , 30 seconds , 156 milliseconds , 7 microseconds\nFreeSWITCH ( Version 1.6.19   64bit ) is ready\n0 session(s ) since startup\n0 session(s ) - peak 0 , last 5min 0 \n0 session(s ) per Sec out of max 30 , peak 0 , last 5min 0 \n1000 session(s ) max\nmin idle cpu 0.00/99.63\nCurrent Stack Size / Max 240K/8192K\n " }
(defmethod parse-event "text/event-json"
  [{:keys [envelope-content] :as msg}]
  (as-> envelope-content $
        (json/read-value $)
        (map (fn [[k v]] [(keyword (str/lower-case k)) (str v)]) $)
        (into {} $)
        (rename-keys $ {:_body :body})
        (dissoc $ :content-length)))

  A typical XML event , decoded with xml / parse :
; {:tag :event,
   : attrs nil ,
;  :content [{:tag :headers,
              : attrs nil ,
;             :content [{:tag :Event-Name,
                         : attrs nil ,
                         : content [ " BACKGROUND_JOB " ] }
;                       {:tag :Core-UUID,
                         : attrs nil ,
;                        :content ["da58d909-f9b2-4393-afe5-d7cf200fee2c"]}
;                       {:tag :FreeSWITCH-Hostname,
                         : attrs nil ,
;                        :content ["oscar-delu"]}
;                       {:tag :FreeSWITCH-Switchname,
                         : attrs nil ,
;                        :content ["oscar-delu"]}
;                       {:tag :FreeSWITCH-IPv4,
                         : attrs nil ,
                         : content [ " 192.168.250.70 " ] }
;                       {:tag :FreeSWITCH-IPv6,
                         : attrs nil ,
;                        :content ["%3A%3A1"]}
;                       {:tag :Event-Date-Local,
                         : attrs nil ,
                         : content [ " 2017 - 11 - 04%2019%3A41%3A10 " ] }
;                       {:tag :Event-Date-GMT,
                         : attrs nil ,
;                        :content ["Sat,%2004%20Nov%202017%2013%3A41%3A10%20GMT"]}
;                       {:tag :Event-Date-Timestamp,
                         : attrs nil ,
;                        :content ["1509802870244410"]}
;                       {:tag :Event-Calling-File,
                         : attrs nil ,
                         : content [ "  " ] }
;                       {:tag :Event-Calling-Function,
                         : attrs nil ,
;                        :content ["api_exec"]}
;                       {:tag :Event-Calling-Line-Number,
                         : attrs nil ,
                         : content [ " 1557 " ] }
;                       {:tag :Event-Sequence,
                         : attrs nil ,
;                        :content ["1534"]}
;                       {:tag :Job-UUID,
                         : attrs nil ,
;                        :content ["35fb4b1c-0909-4284-9b6d-dab6437657a6"]}
                        { : tag : Job - Command ,
                         : attrs nil ,
;                        :content ["status"]}]}
;            {:tag :Content-Length,
              : attrs nil ,
              : content [ " 330 " ] }
;            {:tag :body,
              : attrs nil ,
              : content [ " UP 0 years , 0 days , 2 hours , 22 minutes , 38 seconds , 445 milliseconds , 571 microseconds\nFreeSWITCH ( Version 1.6.19   64bit ) is ready\n0 session(s ) since startup\n0 session(s ) - peak 0 , last 5min 0 \n0 session(s ) per Sec out of max 30 , peak 0 , last 5min 0 \n1000 session(s ) max\nmin idle cpu 0.00/97.70\nCurrent   /  240K/8192K\n   " ] } ] }
(defn- simplify-xml-elem
  "Simplify the map returned by xml/parse."
  [xml-elem]
  (let [{:keys [tag content]} xml-elem]
    (if (every? string? content)
      [tag (apply str content)]
      [tag (->> content
                (map simplify-xml-elem)
                (into {}))])))

(defmethod parse-event "text/event-xml"
  [{:keys [^String envelope-content] :as msg}]
  (let [[_ xmap] (-> envelope-content
                     (.getBytes)
                     (java.io.ByteArrayInputStream.)
                     (xml/parse)
                     (simplify-xml-elem))
        {:keys [headers Content-Length body]} xmap
  Freeswitch adds extra junk at the end of the body (
  a side - effect of the nice indentation of the XML response ) .
        body' (if Content-Length
                (subs body 0 (Integer/parseInt Content-Length))
                body)]
    (as-> headers $
          (map (fn [[k v]] [(keyword (str/lower-case (name k))) (url-decode v)]) $)
          (into {} $)
          (assoc $ :body body'))))

 Filename: src/freeswitch_clj/protocol.clj

 Please see the attached LICENSE file in project root.
 Hide this namespace from public doc in cljdoc.org website.
 Add headers.
 Add a content-length header, if body is present.
 Add message completion indicator.
 If present, add body.
 Can't find a message terminator in the buffer.
 Message has nothing but envelope headers.
 Don't have the full content body. Need more bytes.
 Message does not have body.
 A typical JSON event content:
 {:tag :event,
  :content [{:tag :headers,
             :content [{:tag :Event-Name,
                       {:tag :Core-UUID,
                        :content ["da58d909-f9b2-4393-afe5-d7cf200fee2c"]}
                       {:tag :FreeSWITCH-Hostname,
                        :content ["oscar-delu"]}
                       {:tag :FreeSWITCH-Switchname,
                        :content ["oscar-delu"]}
                       {:tag :FreeSWITCH-IPv4,
                       {:tag :FreeSWITCH-IPv6,
                        :content ["%3A%3A1"]}
                       {:tag :Event-Date-Local,
                       {:tag :Event-Date-GMT,
                        :content ["Sat,%2004%20Nov%202017%2013%3A41%3A10%20GMT"]}
                       {:tag :Event-Date-Timestamp,
                        :content ["1509802870244410"]}
                       {:tag :Event-Calling-File,
                       {:tag :Event-Calling-Function,
                        :content ["api_exec"]}
                       {:tag :Event-Calling-Line-Number,
                       {:tag :Event-Sequence,
                        :content ["1534"]}
                       {:tag :Job-UUID,
                        :content ["35fb4b1c-0909-4284-9b6d-dab6437657a6"]}
                        :content ["status"]}]}
            {:tag :Content-Length,
            {:tag :body,
  Author :   <  >
  Copyright : 2021   <  >
  This work is distributed under MIT Public License .
(ns ^{:doc "Functions to encode & decode text based protocol of freeswitch ESL."
      :author "Titon Barua"
      :no-doc true}
 freeswitch-clj.protocol
  (:require [clojure.string :as str]
            [clojure.xml :as xml]
            [clojure.set :refer [rename-keys]]

            [jsonista.core :as json ]))

(def ^:private lineend "\n")
(def ^:private double-lineend "\n\n")
(def ^:private re-double-lineend #"\n\n")

(defn url-decode
  "Decode only percent encoded chars from a string."
  [text]
  (str/replace text
               #"%([0-9A-Fa-f]{2})"
               (fn [[_ hexed-char]]
                 (str (char (Integer/parseInt hexed-char 16))))))

(defn- absorb-newlines
  "Replace consecutive newlines (and surrounding whitespace chars)
   in a string with a single space."
  [s]
  (str/replace s #"\s*[\r\n]+\s*" " "))

(defn encode-headers
  "Encode a header-map for outgoing message."
  [hdrs]
  (->> hdrs
       (map (fn [[k v]]
              (str (name k)
                   ": "
                   (-> v (str) (str/trim) (absorb-newlines)))))
       (sort-by (fn [[k v]] k))
       (str/join lineend)))

(defn encode
  "Encode an outgoing message."
  [cmd-line cmd-hdrs ^String cmd-body]
  (str (->> cmd-line
            (map str/trim)
            (str/join " ")
            (absorb-newlines))

       (if (empty? cmd-hdrs)
         ""
         (str lineend
              (encode-headers cmd-hdrs)))

       (if (empty? cmd-body)
         ""
         (str lineend
              "Content-Length: "
              (count (.getBytes cmd-body))))

       double-lineend

       (if (empty? cmd-body) "" cmd-body)))

(defn decode-envelope-headers
  "Decode an envelope-header section from incoming message into a map."
  [data]
  (->> data
       str/split-lines
       (map #(str/split % #"\s*:\s*" 2))
       (map (fn [[k v]] [(keyword (str/lower-case k)) v]))
       (into {})))

(defn decode-headers
  "Decode a header section from incoming message into a map."
  [data]
  (->> data
       (decode-envelope-headers)
       (map (fn [[k v]] [k (url-decode v)]))
       (into {})))

(defn decode
  "Extract first message from the data. Returns both the decoded
message and rest of the data in a vector. If no message could be
decoded, message is nil."
  [data]
  (if-not (str/includes? data double-lineend)
    [nil data]

    (let [[hdrs data-rest] (str/split data re-double-lineend 2)
          hdrs' (decode-envelope-headers hdrs)
          clen (get hdrs' :content-length)]
      (if-not clen
        [{:envelope-headers hdrs'
          :envelope-content nil}
         data-rest]

        (let [clen' (Integer/parseInt clen)]
          (if (< (count data-rest) clen')
            [nil data]

  Parse content - body .
            (let [content (subs data-rest 0 clen')
                  data-rest' (subs data-rest clen')]
              [{:envelope-headers hdrs'
                :envelope-content content}
               data-rest'])))))))

(defn decode-all
  "Extract all messages from the given string data.
Also returns rest of the data."
  [data]
  (loop [msgs [], data data]
    (let [[m data-rest] (decode data)]
      (if m
        (recur (conj msgs m) data-rest)
        [msgs data-rest]))))

(defn parse-command-reply
  "Parse a 'command/reply' message."
  [{:keys [envelope-headers envelope-content] :as msg}]
  (assert (= (envelope-headers :content-type) "command/reply"))
  (let [reply-text (envelope-headers :reply-text)
        ok (str/starts-with? reply-text "+")]
    (-> envelope-headers
        (dissoc :content-type)
        (assoc :ok ok))))

(defn parse-api-response
  "Parse an 'api/response' message."
  [{:keys [envelope-headers envelope-content] :as msg}]
  (assert (= (envelope-headers :content-type) "api/response"))
  (let [ok (not (str/starts-with? envelope-content "-"))]
    (-> envelope-headers
        (dissoc :content-type)
        (dissoc :content-length)
        (assoc :ok ok)
        (assoc :result envelope-content))))

(defn parse-bgapi-response
  "Parse result from an event generated by a 'bgapi' request."
  [{:keys [body] :as event}]
  (let [ok (not (str/starts-with? body "-ERR"))]
    {:ok ok
     :result body
     :event event}))

(defmulti parse-event
  "Extract events from messages with content-type 'text/event-*'."
  (fn [{:keys [envelope-headers] :as msg}]
    (envelope-headers :content-type)))

(defmethod parse-event "text/event-plain"
  [{:keys [envelope-content] :as msg}]
  (let [[hdrs body] (str/split envelope-content re-double-lineend 2)
        hdrs' (decode-headers hdrs)
        clen (get hdrs' :content-length)]
    (if-not clen
      (assoc hdrs' :body nil)
      (do (assert (= (count body)
                     (Integer/parseInt (hdrs' :content-length))))
          (-> hdrs'
              (dissoc :content-length)
              (assoc :body body))))))

  { " Event - Name":"BACKGROUND_JOB","Core - UUID":"6fa5901e-72ea-4399 - b689 - afe55a84a2c5","FreeSWITCH - Hostname":"oscar - delu","FreeSWITCH - Switchname":"oscar - delu","FreeSWITCH - IPv4":"192.168.250.70","FreeSWITCH - IPv6":"::1","Event - Date - Local":"2017 - 11 - 05 13:07:27","Event - Date - GMT":"Sun , 05 Nov 2017     - Timestamp":"1509865647430878","Event - Calling - File":"mod_event_socket.c","Event - Calling - Function":"api_exec","Event - Calling - Line - Number":"1557","Event - Sequence":"549","Job - UUID":"d4cd5087 - 130f-4df7 - 994d-885d77673d3d","Job - Command":"status","Content - Length":"327","_body":"UP 0 years , 0 days , 0 hours , 3 minutes , 30 seconds , 156 milliseconds , 7 microseconds\nFreeSWITCH ( Version 1.6.19   64bit ) is ready\n0 session(s ) since startup\n0 session(s ) - peak 0 , last 5min 0 \n0 session(s ) per Sec out of max 30 , peak 0 , last 5min 0 \n1000 session(s ) max\nmin idle cpu 0.00/99.63\nCurrent Stack Size / Max 240K/8192K\n " }
(defmethod parse-event "text/event-json"
  [{:keys [envelope-content] :as msg}]
  (as-> envelope-content $
        (json/read-value $)
        (map (fn [[k v]] [(keyword (str/lower-case k)) (str v)]) $)
        (into {} $)
        (rename-keys $ {:_body :body})
        (dissoc $ :content-length)))

  A typical XML event , decoded with xml / parse :
   : attrs nil ,
              : attrs nil ,
                         : attrs nil ,
                         : content [ " BACKGROUND_JOB " ] }
                         : attrs nil ,
                         : attrs nil ,
                         : attrs nil ,
                         : attrs nil ,
                         : content [ " 192.168.250.70 " ] }
                         : attrs nil ,
                         : attrs nil ,
                         : content [ " 2017 - 11 - 04%2019%3A41%3A10 " ] }
                         : attrs nil ,
                         : attrs nil ,
                         : attrs nil ,
                         : content [ "  " ] }
                         : attrs nil ,
                         : attrs nil ,
                         : content [ " 1557 " ] }
                         : attrs nil ,
                         : attrs nil ,
                        { : tag : Job - Command ,
                         : attrs nil ,
              : attrs nil ,
              : content [ " 330 " ] }
              : attrs nil ,
              : content [ " UP 0 years , 0 days , 2 hours , 22 minutes , 38 seconds , 445 milliseconds , 571 microseconds\nFreeSWITCH ( Version 1.6.19   64bit ) is ready\n0 session(s ) since startup\n0 session(s ) - peak 0 , last 5min 0 \n0 session(s ) per Sec out of max 30 , peak 0 , last 5min 0 \n1000 session(s ) max\nmin idle cpu 0.00/97.70\nCurrent   /  240K/8192K\n   " ] } ] }
(defn- simplify-xml-elem
  "Simplify the map returned by xml/parse."
  [xml-elem]
  (let [{:keys [tag content]} xml-elem]
    (if (every? string? content)
      [tag (apply str content)]
      [tag (->> content
                (map simplify-xml-elem)
                (into {}))])))

(defmethod parse-event "text/event-xml"
  [{:keys [^String envelope-content] :as msg}]
  (let [[_ xmap] (-> envelope-content
                     (.getBytes)
                     (java.io.ByteArrayInputStream.)
                     (xml/parse)
                     (simplify-xml-elem))
        {:keys [headers Content-Length body]} xmap
  Freeswitch adds extra junk at the end of the body (
  a side - effect of the nice indentation of the XML response ) .
        body' (if Content-Length
                (subs body 0 (Integer/parseInt Content-Length))
                body)]
    (as-> headers $
          (map (fn [[k v]] [(keyword (str/lower-case (name k))) (url-decode v)]) $)
          (into {} $)
          (assoc $ :body body'))))
