Как вы думаете, можно ли, используя только Mikrotik скрипт, написать интерактивный Telegram бот, который будет работать целиком в среде маршрутизатора с поддержкой Webhook, входящих событий от API Telegram?

Предисловие:
Долго откладывал написание этой статьи, но недавние события вокруг мессенджера Telegram подстегнули меня с новой энергией взяться за задуманное. Считаю, что Telegram кроме прочего удобная нишевая платформа для информационных диалоговых сервисов, и она должна существовать и здорово конкурировать с другими системами без искусственных ограничений. Пусть эта статья будет скромным вкладом в поддержку Telegram.


Прежде чем ответить на заданный вопрос, нужно понять, что минимально требуется от платформы бота для работы Webhook. А вот что: наличие WEB сервера с SSL, валидный SSL сертификат или самоподписанный сертификат, загружаемый в API Telegram, URL адрес WEB сервера для обработки Webhook. И если доступ из Internet (реальный IP, доменное имя) к маршрутизатору обеспечить можно, то с WEB сервером (тут уже не до SSL даже) у Mikrotik проблемы, пользовательского сервера просто нет. Но эту проблему можно обойти, ниже будет предложено решение.

Telegram бот для Mikrotik — это только «вершина айсберга». В его основе лежит написанный мной полноценный (насколько это было возможно) парсер JSON на скриптовом языке Mikrotik. Вообще для написания среднего бота не обязательно делать полный разбор JSON, можно вполне обойтись поиском и копированием в строках, но я выбрал другой путь. Далее я расскажу про парсер и некоторые приемы программирования Mikrotik скрипт, освоенные во время работы над ним.

Парсер JSON строки на языке Mikrotik


Признаюсь, создание парсера JSON на скрипт-языке Mikrotik для меня было видом спорта. Было интересно, можно ли такое вообще проделать, учитывая ограничения скриптого языка Mikrotik. Но чем дальше погружался в код, тем явственнее виделись пути следования к конечной цели. Чуть ранее я доводил до ума аналогичный парсер на VBScript, найденный на просторах сети, для нужд одной SCADA-системы, поэтому за основу взял логику именной той VBScript реализации, переработал ее с учетом конструкций языка Mikrotik и оформил код в виде библиотеки функций. По пути обнаружил несколько интересных возможностей скриптового языка, которыми с удовольствием поделюсь ниже. Пара слов об ограничениях. Первое: длина строки в переменных Mikrotik 4096 байт, тут уж ничего не поделаешь, все что больше просто не присваивается переменной. Второе: Mikrotik ничего не знает о вещественных числах, поэтому float парсер сохраняет как строковую переменную, типы bool, int, string нормально парсятся во внутреннее представление.

Использование JSON парсера




Функции представлены библиотечным файлом JParseFunctions, который «разворачивает» код функций в глобальные переменные. Эту библиотеку можно вызывать в скриптах сколько угодно раз, без особой потери производительности, для каждой функции делается проверка на «развернутость» ее в глобальных переменных, чтобы избежать дублирование действий. При редактировании библиотечного файла, требуется удалить глобальные переменные — код функций, чтобы они «пересоздались» с учетом обновлений.

Код библиотеки JParseFunctions:

JParseFunctions
# -------------------------------- JParseFunctions ---------------------------------------------------
# ------------------------------- fJParsePrint ----------------------------------------------------------------
:global fJParsePrint
:if (!any $fJParsePrint) do={ :global fJParsePrint do={
  :global JParseOut
  :local TempPath
  :global fJParsePrint

  :if ([:len $1] = 0) do={
    :set $1 "\$JParseOut"
    :set $2 $JParseOut
   }
  
  :foreach k,v in=$2 do={
    :if ([:typeof $k] = "str") do={
      :set k "\"$k\""
    }
    :set TempPath ($1. "->" . $k)
    :if ([:typeof $v] = "array") do={
      :if ([:len $v] > 0) do={
        $fJParsePrint $TempPath $v
      } else={
        :put "$TempPath = [] ($[:typeof $v])"
      }
    } else={
        :put "$TempPath = $v ($[:typeof $v])"
    }
  }
}}
# ------------------------------- fJParsePrintVar ----------------------------------------------------------------
:global fJParsePrintVar
:if (!any $fJParsePrintVar) do={ :global fJParsePrintVar do={
  :global JParseOut
  :local TempPath
  :global fJParsePrintVar
  :local fJParsePrintRet ""

  :if ([:len $1] = 0) do={
    :set $1 "\$JParseOut"
    :set $2 $JParseOut
   }
  
  :foreach k,v in=$2 do={
    :if ([:typeof $k] = "str") do={
      :set k "\"$k\""
    }
    :set TempPath ($1. "->" . $k)
    :if ($fJParsePrintRet != "") do={
      :set fJParsePrintRet ($fJParsePrintRet . "\r\n")
    }   
    :if ([:typeof $v] = "array") do={
      :if ([:len $v] > 0) do={
        :set fJParsePrintRet ($fJParsePrintRet . [$fJParsePrintVar $TempPath $v])
      } else={
        :set fJParsePrintRet ($fJParsePrintRet . "$TempPath = [] ($[:typeof $v])")
      }
    } else={
        :set fJParsePrintRet ($fJParsePrintRet . "$TempPath = $v ($[:typeof $v])")
    }
  }
  :return $fJParsePrintRet
}}
# ------------------------------- fJSkipWhitespace ----------------------------------------------------------------
:global fJSkipWhitespace
:if (!any $fJSkipWhitespace) do={ :global fJSkipWhitespace do={
  :global Jpos
  :global JSONIn
  :global Jdebug
  :while ($Jpos < [:len $JSONIn] and ([:pick $JSONIn $Jpos] ~ "[ \r\n\t]")) do={
    :set Jpos ($Jpos + 1)
  }
  :if ($Jdebug) do={:put "fJSkipWhitespace: Jpos=$Jpos Char=$[:pick $JSONIn $Jpos]"}
}}
# -------------------------------- fJParse ---------------------------------------------------------------
:global fJParse
:if (!any $fJParse) do={ :global fJParse do={
  :global Jpos
  :global JSONIn
  :global Jdebug
  :global fJSkipWhitespace
  :local Char

  :if (!$1) do={
    :set Jpos 0
   }
 
  $fJSkipWhitespace
  :set Char [:pick $JSONIn $Jpos]
  :if ($Jdebug) do={:put "fJParse: Jpos=$Jpos Char=$Char"}
  :if ($Char="{") do={
    :set Jpos ($Jpos + 1)
    :global fJParseObject
    :return [$fJParseObject]
  } else={
    :if ($Char="[") do={
      :set Jpos ($Jpos + 1)
      :global fJParseArray
      :return [$fJParseArray]
    } else={
      :if ($Char="\"") do={
        :set Jpos ($Jpos + 1)
        :global fJParseString
        :return [$fJParseString]
      } else={
#        :if ([:pick $JSONIn $Jpos ($Jpos+2)]~"^-\?[0-9]") do={
        :if ($Char~"[eE0-9.+-]") do={
          :global fJParseNumber
          :return [$fJParseNumber]
        } else={

          :if ($Char="n" and [:pick $JSONIn $Jpos ($Jpos+4)]="null") do={
            :set Jpos ($Jpos + 4)
            :return []
          } else={
            :if ($Char="t" and [:pick $JSONIn $Jpos ($Jpos+4)]="true") do={
              :set Jpos ($Jpos + 4)
              :return true
            } else={
              :if ($Char="f" and [:pick $JSONIn $Jpos ($Jpos+5)]="false") do={
                :set Jpos ($Jpos + 5)
                :return false
              } else={
                :put "Err.Raise 8732. No JSON object could be fJParseed"
                :set Jpos ($Jpos + 1)
                :return []
              }
            }
          }
        }
      }
    }
  }
}}

#-------------------------------- fJParseString ---------------------------------------------------------------
:global fJParseString
:if (!any $fJParseString) do={ :global fJParseString do={
  :global Jpos
  :global JSONIn
  :global Jdebug
  :global fUnicodeToUTF8
  :local Char
  :local StartIdx
  :local Char2
  :local TempString ""
  :local UTFCode
  :local Unicode

  :set StartIdx $Jpos
  :set Char [:pick $JSONIn $Jpos]
  :if ($Jdebug) do={:put "fJParseString: Jpos=$Jpos Char=$Char"}
  :while ($Jpos < [:len $JSONIn] and $Char != "\"") do={
    :if ($Char="\\") do={
      :set Char2 [:pick $JSONIn ($Jpos + 1)]
      :if ($Char2 = "u") do={
        :set UTFCode [:tonum "0x$[:pick $JSONIn ($Jpos+2) ($Jpos+6)]"]
        :if ($UTFCode>=0xD800 and $UTFCode<=0xDFFF) do={
# Surrogate pair
          :set Unicode  (($UTFCode & 0x3FF) << 10)
          :set UTFCode [:tonum "0x$[:pick $JSONIn ($Jpos+8) ($Jpos+12)]"]
          :set Unicode ($Unicode | ($UTFCode & 0x3FF) | 0x10000)
          :set TempString ($TempString . [:pick $JSONIn $StartIdx $Jpos] . [$fUnicodeToUTF8 $Unicode])        
          :set Jpos ($Jpos + 12)
        } else= {
# Basic Multilingual Plane (BMP)
          :set Unicode $UTFCode
          :set TempString ($TempString . [:pick $JSONIn $StartIdx $Jpos] . [$fUnicodeToUTF8 $Unicode])
          :set Jpos ($Jpos + 6)
        }
        :set StartIdx $Jpos
        :if ($Jdebug) do={:put "fJParseString Unicode: $Unicode"}
      } else={
        :if ($Char2 ~ "[\\bfnrt\"]") do={
          :if ($Jdebug) do={:put "fJParseString escape: Char+Char2 $Char$Char2"}
          :set TempString ($TempString . [:pick $JSONIn $StartIdx $Jpos] . [[:parse "(\"\\$Char2\")"]])
          :set Jpos ($Jpos + 2)
          :set StartIdx $Jpos
        } else={
          :if ($Char2 = "/") do={
            :if ($Jdebug) do={:put "fJParseString /: Char+Char2 $Char$Char2"}
            :set TempString ($TempString . [:pick $JSONIn $StartIdx $Jpos] . "/")
            :set Jpos ($Jpos + 2)
            :set StartIdx $Jpos
          } else={
            :put "Err.Raise 8732. Invalid escape"
            :set Jpos ($Jpos + 2)
          }
        }
      }
    } else={
      :set Jpos ($Jpos + 1)
    }
    :set Char [:pick $JSONIn $Jpos]
  }
  :set TempString ($TempString . [:pick $JSONIn $StartIdx $Jpos])
  :set Jpos ($Jpos + 1)
  :if ($Jdebug) do={:put "fJParseString: $TempString"}
  :return $TempString
}}

#-------------------------------- fJParseNumber ---------------------------------------------------------------
:global fJParseNumber
:if (!any $fJParseNumber) do={ :global fJParseNumber do={
  :global Jpos
  :local StartIdx
  :global JSONIn
  :global Jdebug
  :local NumberString
  :local Number

  :set StartIdx $Jpos  
  :set Jpos ($Jpos + 1)
  :while ($Jpos < [:len $JSONIn] and [:pick $JSONIn $Jpos]~"[eE0-9.+-]") do={
    :set Jpos ($Jpos + 1)
  }
  :set NumberString [:pick $JSONIn $StartIdx $Jpos]
  :set Number [:tonum $NumberString]
  :if ([:typeof $Number] = "num") do={
    :if ($Jdebug) do={:put "fJParseNumber: StartIdx=$StartIdx Jpos=$Jpos $Number ($[:typeof $Number])"}
    :return $Number
  } else={
    :if ($Jdebug) do={:put "fJParseNumber: StartIdx=$StartIdx Jpos=$Jpos $NumberString ($[:typeof $NumberString])"}
    :return $NumberString
  }
}}

#-------------------------------- fJParseArray ---------------------------------------------------------------
:global fJParseArray
:if (!any $fJParseArray) do={ :global fJParseArray do={
  :global Jpos
  :global JSONIn
  :global Jdebug
  :global fJParse
  :global fJSkipWhitespace
  :local Value
  :local ParseArrayRet [:toarray ""]
 
  $fJSkipWhitespace   
  :while ($Jpos < [:len $JSONIn] and [:pick $JSONIn $Jpos]!= "]") do={
    :set Value [$fJParse true]
    :set ($ParseArrayRet->([:len $ParseArrayRet])) $Value
    :if ($Jdebug) do={:put "fJParseArray: Value="; :put $Value}
    $fJSkipWhitespace
    :if ([:pick $JSONIn $Jpos] = ",") do={
      :set Jpos ($Jpos + 1)
      $fJSkipWhitespace
    }
  }
  :set Jpos ($Jpos + 1)
#  :if ($Jdebug) do={:put "ParseArrayRet: "; :put $ParseArrayRet}
  :return $ParseArrayRet
}}

# -------------------------------- fJParseObject ---------------------------------------------------------------
:global fJParseObject
:if (!any $fJParseObject) do={ :global fJParseObject do={
  :global Jpos
  :global JSONIn
  :global Jdebug
  :global fJSkipWhitespace
  :global fJParseString
  :global fJParse
# Syntax :local ParseObjectRet ({}) don't work in recursive call, use [:toarray ""] for empty array!!!
  :local ParseObjectRet [:toarray ""]
  :local Key
  :local Value
  :local ExitDo false
 
  $fJSkipWhitespace
  :while ($Jpos < [:len $JSONIn] and [:pick $JSONIn $Jpos]!="}" and !$ExitDo) do={
    :if ([:pick $JSONIn $Jpos]!="\"") do={
      :put "Err.Raise 8732. Expecting property name"
      :set ExitDo true
    } else={
      :set Jpos ($Jpos + 1)
      :set Key [$fJParseString]
      $fJSkipWhitespace
      :if ([:pick $JSONIn $Jpos] != ":") do={
        :put "Err.Raise 8732. Expecting : delimiter"
        :set ExitDo true
      } else={
        :set Jpos ($Jpos + 1)
        :set Value [$fJParse true]
        :set ($ParseObjectRet->$Key) $Value
        :if ($Jdebug) do={:put "fJParseObject: Key=$Key Value="; :put $Value}
        $fJSkipWhitespace
        :if ([:pick $JSONIn $Jpos]=",") do={
          :set Jpos ($Jpos + 1)
          $fJSkipWhitespace
        }
      }
    }
  }
  :set Jpos ($Jpos + 1)
#  :if ($Jdebug) do={:put "ParseObjectRet: "; :put $ParseObjectRet}
  :return $ParseObjectRet
}}

# ------------------- fByteToEscapeChar ----------------------
:global fByteToEscapeChar
:if (!any $fByteToEscapeChar) do={ :global fByteToEscapeChar do={
#  :set $1 [:tonum $1]
  :return [[:parse "(\"\\$[:pick "0123456789ABCDEF" (($1 >> 4) & 0xF)]$[:pick "0123456789ABCDEF" ($1 & 0xF)]\")"]]
}}

# ------------------- fUnicodeToUTF8----------------------
:global fUnicodeToUTF8
:if (!any $fUnicodeToUTF8) do={ :global fUnicodeToUTF8 do={
  :global fByteToEscapeChar
#  :local Ubytes [:tonum $1]
  :local Nbyte
  :local EscapeStr ""

  :if ($1 < 0x80) do={
    :set EscapeStr [$fByteToEscapeChar $1]
  } else={
    :if ($1 < 0x800) do={
      :set Nbyte 2
    } else={ 
      :if ($1 < 0x10000) do={
        :set Nbyte 3
      } else={
        :if ($1 < 0x20000) do={
          :set Nbyte 4
        } else={
          :if ($1 < 0x4000000) do={
            :set Nbyte 5
          } else={
            :if ($1 < 0x80000000) do={
              :set Nbyte 6
            }
          }
        }
      }
    }
    :for i from=2 to=$Nbyte do={
      :set EscapeStr ([$fByteToEscapeChar ($1 & 0x3F | 0x80)] . $EscapeStr)
      :set $1 ($1 >> 6)
    }
    :set EscapeStr ([$fByteToEscapeChar (((0xFF00 >> $Nbyte) & 0xFF) | $1)] . $EscapeStr)
  }
  :return $EscapeStr
}}

# ------------------- End JParseFunctions----------------------

Рассмотрим работу парсера на примере куска кода Telegram бота. Выполним пошагово следующие команды.

Запрос состояния функции getWebhookInfo API Telegram, которая возвращает JSON строку в файл j.txt:

:do {/tool fetch url="https://api.telegram.org/bot$TToken/getWebhookInfo" dst-path=j.txt} on-error={:put "getWebhookInfo error"};

[admin@MikroTik] > :put [/file get j.txt contents];
{"ok":true,"result":{"url":"https://*****:8443","has_custom_certificate":false,"pending_update_count":0,"last_error_date":1524565055,"last_error_message":"Connection timed out","max_connections":4
0}}

Загрузка JSON строки во входную переменную:

:set JSONIn [/file get j.txt contents]

Выполнение функции парсера $fJParse и выгрузка результата в переменную $JParseOut

:set JParseOut [$fJParse];

В $JParseOut можно найти ассоциативный массив, который является отображением исходной JSON строки на массивы и типы данных Mikrotik. Содержимое тут не привожу, оно приводится ниже.

Можно задать глобальну переменную $Jdebug (true), тогда в ручной режиме при вызове функции в консоле маршрутизатора можно получить дополнительный вывод для нужд отладки.

Многомерные ассоциативные массивы


В языке Mikrotik поддерживаются вложенные (многомерные) ассоциативные массивы.
Вот пример вывода глобальной переменной $JParseOut, в которую записывается результат работы парсера:

[admin@MikroTik] > :put $JParseOut      
ok=true;result=has_custom_certificate=false;max_connections=40;pending_update_count=0;url=https://*****.ru:8443

[admin@MikroTik] > :put ($JParseOut->"result")   
has_custom_certificate=false;max_connections=40;pending_update_count=0;url=https://*****:8443

[admin@MikroTik] > :put ($JParseOut->"result"->"max_connections")
40

Видно, что ключ «result» содержит в качестве значения также ассоциативный массив, до элементов которого можно добраться, используя цепочку "->". Причем важно, что все элементы имеют свой тип данных (число, строка, булевый, массив):

[admin@MikroTik] > :put [:typeof ($JParseOut->"result")]                   
array

[admin@MikroTik] > :put [:typeof ($JParseOut->"result"->"max_connections")]
num

[admin@MikroTik] > :put [:typeof ($JParseOut->"result"->"url")]               
str

Именно эксперименты с этой многоуровневой конструкцией натолкнули на мысль о создании JSON парсера. Формат JSON неплохо перекладывается в такое внутреннее представления скриптового языка Mikrotik.

Функции, рекурсивный вызов


Для многих не секрет, что можно определять свои фукции, на форуме сайта www.mikrotik.com можно найти много примеров таких конструкций. Мой парсер также построен на функциях, вложенных и рекурсивных вызовах. Да, поддерживается рекурсивный вызов функций!

В качестве примера приведу функцию $fJParsePrint из набора парсера, печатающую в читаемом виде содержимое ассоциативного массива $JParseOut (а точнее в виде путей, которые можно скопировать и использовать в своих скриптах для доступа к элементам массива) и результат ее работы:

:global fJParsePrint
:if (!any $fJParsePrint) do={ :global fJParsePrint do={
  :global JParseOut
  :local TempPath
  :global fJParsePrint

  :if ([:len $1] = 0) do={
    :set $1 "\$JParseOut"
    :set $2 $JParseOut
   }
  
  :foreach k,v in=$2 do={
    :if ([:typeof $k] = "str") do={
      :set k "\"$k\""
    }
    :set TempPath ($1. "->" . $k)
    :if ([:typeof $v] = "array") do={
      :if ([:len $v] > 0) do={
        $fJParsePrint $TempPath $v
      } else={
        :put "$TempPath = [] ($[:typeof $v])"
      }
    } else={
        :put "$TempPath = $v ($[:typeof $v])"
    }
  }
}}

[admin@MikroTik] > $fJParsePrint                      
$JParseOut->"ok" = true (bool)
$JParseOut->"result"->"has_custom_certificate" = false (bool)
$JParseOut->"result"->"last_error_date" = 1524483204 (num)
$JParseOut->"result"->"last_error_message" = Connection timed out (str)
$JParseOut->"result"->"max_connections" = 40 (num)
$JParseOut->"result"->"pending_update_count" = 0 (num)
$JParseOut->"result"->"url" = https://*****.ru:8443 (str)

Можно увидеть в коде функции рекурсивный вызов, который передает внутрь функции текущий уровень вложения и элемент-подмассив, таким образом осуществляется обход всего дерева массивов в переменной $JParseOut.

$fJParsePrint $TempPath $v

Для интереса можно вызвать эту функцию с параметрами из консоли, указать начальный путь вывода, например, «home», и переменную массива вручную:

[admin@MikroTik] > $fJParsePrint "home" $JParseOut
home->"ok" = true (bool)
home->"result"->"has_custom_certificate" = false (bool)
home->"result"->"last_error_date" = 1524483204 (num)
home->"result"->"last_error_message" = Connection timed out (str)
home->"result"->"max_connections" = 40 (num)
home->"result"->"pending_update_count" = 0 (num)
home->"result"->"url" = https://*****.ru:8443 (str)

Функция написана так, чтобы обрабатывать вызов с параметрами и без, т.е. используется переменное число параметров. Традиционно перед обращением нужно объявлять (точнее декларировать) глобальные переменные и функции внутри блока, в данном случае в теле функции. Обратите внимание, что присутствует объявление ":global fJParsePrint", т.е. объявляется сама же функция, ничего удивительного, это нужно для рекурсивного вызова.

Парсинг строки с кодом «налету» и ее выполнение


Давайте рассмотрим функцию $fByteToEscapeChar:

:global fByteToEscapeChar
:if (!any $fByteToEscapeChar) do={ :global fByteToEscapeChar do={
#  :set $1 [:tonum $1]
  :return [[:parse "(\"\\$[:pick "0123456789ABCDEF" (($1 >> 4) & 0xF)]$[:pick "0123456789ABCDEF" ($1 & 0xF)]\")"]]
}}

Эта функция преобразует параметр $1 (байтовое число) в строковый символ, т.е. осуществляет преобразование кода ASCII в символ. Вот, например, есть код 0x2B, которому соответствует символ "+". Задать кодом символ можно, используя экранирование "\NN", где NN — ASCII код, но только в строке:

[admin@MikroTik] > :put "\2B"         
+

Но вот если исходный код представлен числом (байтом), то получение символа не простая задача, так как готовой встроенной функции для этого нет. Тут приходит на помощь другая встроенная функция parse, позволяющая собрать строку — выражение, управляющую последовательность на базе исходного числа, например, "(\2B)".

Выражение вида:

:put [:parse "(\"\\$[:pick "0123456789ABCDEF" ((0x2B >> 4) & 0xF)]$[:pick "0123456789ABCDEF" (0x2B & 0xF)]\")"]
(<%% + )

— собирает строку кода, которую нужно выполнить, чтобы получить строковый символ на выходе. Второе выполнение полученного после parse кода делается с помощью тех же квадратных скобок [...], таким образом конечное выражение приобретает довольно замысловатый вид, обрамление двойными квадратными скобками [[...]], после выполнения которого получаем ожидаемый символ:

[admin@MikroTik] > :put [[:parse "(\"\\$[:pick "0123456789ABCDEF" ((0x2B >> 4) & 0xF)]$[:pick "0123456789ABCDEF" (0x2B & 0xF)]\")"]]
+

Telegram бот на базе JSON парсера


Polling бот


Теперь, когда мы легко можем получить доступ к содержимому JSON ответов от API Telegram, напишем первый вариант бота, работающего в режиме polling, т.е. периодического запроса API Telegram. Он будет отвечать на некоторые команды, например, uptime — запрос времени работы маршрутизатора, ip — запрос всех DHCP Client IP адресов, parse — вывод содержимого переменной $JParseOut, т.е. распарсенный JSON ответ на последний запрос. При вводе любых других команд или символов, бот просто будет отвечать эхом.

Этот бот представляет собой один скрипт, который вызывается периодически из планировщика, например раз в минуту и читает getUpdates Функцию API telegram, после разбора ответа делает if-else выбор действия по переменной $v->«message»->«text». Также хочу обратить внимание на вызов функции «text=$[$fJParsePrintVar]» из набора функций парсера, которая возвращает в читаемом виде содержимое $JParseOut. Полный код бота представлен ниже.

Из плюсов: так как инициирует обмен скрипт, то будет работать через NAT без настроек.
Минусы такой реализации: скорость ответа Mikrotik на запрос определяется частотой вызова скрипта, при каждом вызове выполняется запрос getUpdates, парсинг, в общем полный цикл запроса-анализа, что нагружает процессор; каждый вызов ведет к записи файла j.txt, для раздела на flash диске это плохо, для RAM диска не страшно.

Код скрипта Polling бота:

TelegramPollingBot
/system script run JParseFunctions
:global TToken "12312312:32131231231"
:global TChatId "43242342423"

:global Toffset
:if ([:typeof $Toffset] != "num") do={:set Toffset 0}
/tool fetch url="https://api.telegram.org/bot$TToken/getUpdates\?chat_id=$TChatId&offset=$Toffset" dst-path=j.txt
#:delay 2
:global JSONIn [/file get j.txt contents]
:global fJParse
:global fJParsePrintVar
:global Jdebug false
:global JParseOut [$fJParse]
:local Results ($JParseOut->"result")

:if ([:len $Results]>0) do={
  :foreach k,v in=$Results do={
    :if (any ($v->"message"->"text")) do={
      :if ($v->"message"->"text" ~ "uptime") do={
        /tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$[/system resource get uptime]" keep-result=no
      } else={
        :if ($v->"message"->"text" ~ "ip") do={
          /tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$[/ip dhcp-client print as-value]" keep-result=no
        } else={
          :if ($v->"message"->"text" ~ "parse") do={
            /tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$[$fJParsePrintVar]" keep-result=no
          } else={
            /tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$($v->"message"->"text")" keep-result=no
          }
        }
      }
    }
    :set $Toffset ($v->"update_id" + 1)
  }
} else={
  :set $Toffset 0
}


Webhook бот


Чтобы избавиться от этих минусов, создадим второй вариант скрипта, который будет обрабатывать Webhook, т.е. когда API Telegram сам «долбится» по заданному адресу в маршрутизатор, чтобы прислать новые сообщения.

Mikrotik, конечно, не умеет делать пользовательский Web сервер внутри себя, который требуется для полноценной работы Webhook уведомлений от API Telegram. Но можно хитро обойти эту проблему. Для этого нужно мониторить некий несуществующий TCP сокет, в который будет «долбиться» Webhook, это делается с помощью Mangle (или Firewall) правила. В API Telegram включается работа с Webhook (функция API setWebhook), указывается доменное имя маршрутизатора и TCP порт, SSL сертификат тут роли не играет никакой, т.е. не нужен! По изменению значения счетчика пакетов правила Mangle можно понять, что в несуществующий TCP порт «долбится» Webhook (или что-то другое ;), лишнее можно отсечь фильтром src-address=149.154.167.192/26). К сожалению, правило Mangle не может напрямую вызывать пользовательский скрипт (нет такого действия), но можно опрашивать счетчик пакетов из скрипта. Скрипт также выполняется по расписанию, но с минимальным интервалом в 1 секунду. В состоянии ожидания выполняется только проверка изменения значения счетчика пакетов. После детектирования нового входящего пакета отсылается запрос в API Telegram на отключение Webhook, и делаются чтение и обработка сообщений как в первом варианте скрипта (polling), затем опять включается Webhook с возвращением в состояние ожидания. Основные шаги проиллюстрированы на диаграмме работы скрипта.



Как уже было сказано, скрипт запускается часто, и, чтобы исключить дублирование экземпляров вызываемого скрипта, в начале скрипта сделана защита от дублирования, там должно быть указано имя этого скрипта.

:if ([:len [/system script job find script=TelegramWebhookBot]] <= 1) do={...}

Код скрипта Webhook бота:

TelegramWebhookBot
:if ([:len [/system script job find script=TelegramWebhookBot]] <= 1) do={
#:while (true) do={
  :global TelegramWebhookPackets
  :local TWebhookURL "https://www.yourdomain"
  :local TWebhookPort "8443"

# Create Telegram webhook mangle action
  :if ([:len [/ip firewall mangle find dst-port=$TWebhookPort]] = 0) do={
    /ip firewall mangle add action=accept chain=prerouting connection-state=new dst-port=$TWebhookPort protocol=tcp src-address=149.154.167.192/26 comment="Telegram"
  }
 
  :if ([/ip firewall mangle get [find dst-port=$TWebhookPort] packets] != $TelegramWebhookPackets) do={
    /system script run JParseFunctions
    :local TToken "123123123:123123123123123"
    :local TChatId "3213123123123"
    :global TelegramOffset
    :global fJParse
    :global fJParsePrintVar
    :global Jdebug false
    :global JSONIn
    :global JParseOut
 
    :if ([:typeof $TelegramOffset] != "num") do={:set TelegramOffset 0}
    :put "getWebhookInfo"
    :do {/tool fetch url="https://api.telegram.org/bot$TToken/getWebhookInfo" dst-path=j.txt} on-error={:put "getWebhookInfo error"}
    :set JSONIn [/file get j.txt contents]
    :set JParseOut [$fJParse]
    :put $JParseOut
    :if ($JParseOut->"result"->"pending_update_count" > 0) do={
      :put "pending_update_count > 0"
      :do {/tool fetch url="https://api.telegram.org/bot$TToken/deleteWebhook"  http-method=get keep-result=no}  on-error={:put "deleteWebhook error"}
      :put "getUpdates"
      :do {/tool fetch url="https://api.telegram.org/bot$TToken/getUpdates\?chat_id=$TChatId&offset=$TelegramOffset" dst-path=j.txt} on-error={:put "getUpdates error"}
      :set JSONIn [/file get j.txt contents]
      :set JParseOut [$fJParse]
      :put $JParseOut
      :if ([:len ($JParseOut->"result")] > 0) do={
        :foreach k,v in=($JParseOut->"result") do={
          :if (any ($v->"message"->"text")) do={
            :if ($v->"message"->"text" ~ "uptime") do={
              :do {/tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$[/system resource get uptime]" keep-result=no} on-error={:put  "sendmessage error"}
            } else={
              :if ($v->"message"->"text" ~ "ip") do={
                :do {/tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$[/ip dhcp-client print as-value]" keep-result=no} on-error={:put "sendmessage error"}
              } else={
                :if ($v->"message"->"text" ~ "parse") do={
                  :do {/tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$[$fJParsePrintVar]" keep-result=no} on-error={:put   "sendmessage error"}
                } else={
                  :if ($v->"message"->"text" ~ "add") do={
                    :local addIP [:toip [:pick ($v->"message"->"text") 4 [:len ($v->"message"->"text")]]]
                    :if ([:typeof $addIP] = "ip") do={
                      :do {/ip firewall address-list add address=$addIP list=ExtAccessIPList timeout=10m comment="temp"} on-error={:put "ip in list error"}
                    }
                    :local Str1 ""
                    :foreach item in=[/ip firewall address-list print as-value where list=ExtAccessIPList and dynamic] do={:set Str1 ($Str1 . "$($item->"address") $($item->"timeout") $($item->"comment")\r\n")}
                    :do {/tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$Str1" keep-result=no} on-error={:put "sendmessage error"}
                  } else={
                    :put ($v->"message"->"text")
                    :do {/tool fetch url="https://api.telegram.org/bot$TToken/sendmessage\?chat_id=$TChatId"  http-method=post  http-data="text=$($v->"message"->"text")" keep-result=no} on-error={:put  "sendmessage error"}
                  }
                }
              }
            }
          }
          :set $TelegramOffset ($v->"update_id" + 1)
        }
      } else={
#        :set $TelegramOffset 0
      }
      :put "getUpdates"
      :do {/tool fetch url="https://api.telegram.org/bot$TToken/getUpdates\?chat_id=$TChatId&offset=$TelegramOffset" keep-result=no} on-error={:put "getUpdates error"}
      :put "setWebhook"
      :do {/tool fetch url="https://api.telegram.org/bot$TToken/setWebhook\?url=$TWebhookURL:$TWebhookPort" keep-result=no} on-error={:put "setWebhook error"}
    } else={
      :if ($JParseOut->"result"->"url"="") do={
        :put "setWebhook"
        :do {/tool fetch url="https://api.telegram.org/bot$TToken/setWebhook\?url=$TWebhookURL:$TWebhookPort" keep-result=no} on-error={:put "setWebhook error"}
      }
    }
  :set TelegramWebhookPackets [/ip firewall mangle get [find dst-port=$TWebhookPort] packets]
  :put "--------------------------------------------------"
  }
}


В этот скрипт бота была добавлена команда «add», которая добавляет на 10 минут IP адрес в разрешающий список адресов ExtAccessIPList.

Пример запроса и ответа в Telegram. Последняя строка — это уже добавленный в IP list временный адрес:

>add 1.1.1.1
>> 90.0.0.97 h*******
100.0.0.157 6*******
90.0.0.2 i*******.ru
100.0ю0.66 b*******.ru
1.1.1.1 00:10:00 temp


Осталось указать минусы и плюсы такого подхода. Минусы: для Webhook нужны доступы к IP и заданному TCP порту маршрутизатора из Internet, фактически реальный IP адрес, желательно привязанный к домену. По поводу наличия доменного имени я не уверен, нужно «курить» API Telegram, возможно оно не позволяет делать Webhook по IP сервера. У меня работает с динамическим реальным IP адресом и сервисом динамического DNS.

Плюсы: основная часть скрипта фактически спит все время, ожидая входящих пакетов на суррогатный сокет. Если скрипт вызывать часто (у меня раз в секунду), то Webhook-и отрабатываются очень быстро, как в нормальных Telegram ботах.

Также исходный код можно найти тут.

И немного видео:


Комментарии (9)


  1. Sersoftin
    27.04.2018 15:48

    Признаюсь, создание парсера JSON на скрипт-языке Mikrotik для меня было видом спорта.

    Прочитав это подумал, что существует такая же статья на английском. Странноватая формулировка.


  1. xmaster83
    27.04.2018 19:47

    Йоу, полегче, полегче… я вот не пойму это искусство интеграции, или извращение?


    1. Chupakabra303 Автор
      28.04.2018 13:13

      Как обычно, истина где-то посередине.


  1. DeSharky
    27.04.2018 22:01

    Микротик головного мозга ))


  1. hexman
    28.04.2018 09:45

    Второй вариант, не совсем уж и отличается от первого, как по мне, так ещё и более костыльный. Смысл?


  1. zedalert
    28.04.2018 10:51

    Написание такого огромного количества скриптов для микротика это не просто подвиг, за такое сама фирма микротик должна премию выдавать. А главное как? Дебага нет, отображения ошибок тоже нет, официальная документация довольно скудная.


    1. Chupakabra303 Автор
      28.04.2018 13:22

      Язык интерпретируемый, есть консоль, понимающая команды языка, с автодополнением. В командной строке быстро можно проверить любые варианты и конструкции. Всегда можно вывод в консоль сделать из работающей программы. Ничем не хуже тех же Linux shell скриптов.


      1. zedalert
        28.04.2018 14:55

        Ну сравнили, bash, которым пользуется раз в 10^4 больше людей, и который задокументирован по самое, с таким довольно узким (в плане распространения и знаний людей) и молодым языком. Консоль с подсказками есть, но она может подсказывать не в тему (когда команда длинная), одна и та же команда в консоли и в скрипте работаю по-разному.


  1. AMaxKaluga
    28.04.2018 13:14

    1. Домен нужен для случая когда на одном IP адресе у вас много web серверов. Т.е. в вашем случае абсолютно не нужен.
    2. Вариант решения попроще. Вижу, что в прошивке у микротика есть NetCat (nc). С помощью него можно открыть слушатель порта с ожиданием 0 байт и при подключении вызывать ваш скрипт. Делать это в цикле.