Хабрахабр

Telegram бот для Mikrotik с Webhook и парсером JSON

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

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

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

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

Парсер 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= :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 ответ на последний запрос. При вводе любых других команд или символов, бот просто будет отвечать эхом.

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

Из плюсов: так как инициирует обмен скрипт, то будет работать через 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. В API Telegram включается работа с Webhook (функция API setWebhook), указывается доменное имя маршрутизатора и TCP порт, SSL сертификат тут роли не играет никакой, т.е. Для этого нужно мониторить некий несуществующий TCP сокет, в который будет «долбиться» Webhook, это делается с помощью Mangle (или Firewall) правила. По изменению значения счетчика пакетов правила Mangle можно понять, что в несуществующий TCP порт «долбится» Webhook (или что-то другое ;), лишнее можно отсечь фильтром src-address=149. не нужен! 167. 154. К сожалению, правило Mangle не может напрямую вызывать пользовательский скрипт (нет такого действия), но можно опрашивать счетчик пакетов из скрипта. 192/26). В состоянии ожидания выполняется только проверка изменения значения счетчика пакетов. Скрипт также выполняется по расписанию, но с минимальным интервалом в 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.

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

1. >add 1. 1
>> 90. 1. 0. 0. 0. 97 h*******
100. 157 6*******
90. 0. 0. 0. 0ю0. 2 i*******.ru
100. 1. 66 b*******.ru
1. 1 00:10:00 temp
1.

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть