Статья

IoBroker. Inline меню для Telegram бота

ioBroker - ВведениеУстановка ioBroker на ОС LinuxioBroker - админкаioBroker - устанавливаем первый драйверioBroker - ВизуализацияioBroker - уведомленияioBroker - управлениeioBroker - Собрать всех вместе и завести в Apple HomeKit

ioBroker - ham - HomeKitioBroker - Inline меню для Telegram бота

В этой статье мы с вами по шагам будем создавать меню для telegram бота Умного Дома. Рекомендую изучить минимальные азы по языку программирования JavaSсript, это облегчит понимание того, что тут вообще происходит.

Приготовления

Сначала необходимо установить драйвера Telegram и Script Engine. Вспомнить как это делается, можно в статье ioBroker - устанавливаем первый драйвер. Не забываем подключить своего бота к драйверу Telegram, как описано в начале статьи - ioBroker - уведомления.

Все готово к созданию меню для Умного дома. 

Создаем Меню

Предварительно необходимо на листике или в уме подготовить набросок древовидной структуры будущего меню

Набросаем наше дерево в скрипте

var button = [, , , , , , , , , ];

Разберем подробнее

В первой строке в квадратных скобках перечисляются основные кнопки (ветки) меню, плюс дополнительно добавляется кнопка Закрыть. Она позволит закрывать меню в чате бота, чтобы у нас не получилось куча сообщений от бота с открытыми менюшками. Ну и в конце текст в кавычках 'Меню' тоже обязателен. В этом месте будет указываться название вышестоящей ветки меню, т.к. первая строка уже является верхушкой дерева, то текст в этом месте дублирует начало.

Вторая строка - переход по дереву ниже на ветку Зал. Соответственно в квадратных скобках уже перечислены кнопки меню Зал. К Закрыть, добавилась кнопка Назад, которая позволит подняться на одну ветку выше и в конце укажем куда - 'Меню'

Третья строка - переход еще ниже в меню Зал. Сцены. Для корректной работы кнопки Назад, в конце пишем - 'Зал'

Отступы для каждой строки сделаны лишь для удобства восприятия структуры меню и никакой функциональности не несут.

Внимательный читатель, надеюсь, обратил внимание что названия веток меню в наброске и в коде отличаются :) Почему так сделано, будет описано дальше.  

Дальнейший код будет описан только в объеме, необходимом для оформления своего меню, плюс краткое пояснение функций.

Добавляем в скрипт весь остальной код. 

var button = [, , , , , , , , , ]; var menuUp = 'Меню'; var first_tap = false; var menu_current; var topTextGlobal; on(, function (obj) { command = obj.state.val.substring(obj.state.val.indexOf(']') + 1); user = obj.state.val.substring(obj.state.val.indexOf('[')+1, obj.state.val.indexOf(']')); //log(command); //log(user); //************************************ // Меню //************************************ if (command ==="/buttons" || command ==="кнопки" || command ==="Кнопки") sendTo('telegram', { user: user, text: 'Показать меню', reply_markup: { keyboard: [['Показать меню']], resize_keyboard: true, one_time_keyboard: true } }); //************************************ // меню inline //************************************ var menu = { reply_markup: { inline_keyboard: [[],[],[],[],[],[],[]], } }; if (command === 'Показать меню') command = menuUp; log (command); if (command === 'Меню') first_tap = true; if (command === '◀️ Назад') command = menuUp; var but1 = getButtonArray(button, 'name', command).toString(); if (but1.length > 0) { // проверяем, что строка не пустая var but2 = but1.split(','); //преобразуем в массив menuUp = but2.pop(); //вырезаем последний элемент if (but2.length > 0) { // проверяем что массив не пуст var index = 0; for (var i=0, len=but2.length; i<len; i++) { menu.reply_markup.inline_keyboard[index].push({ text: but2[i], callback_data: but2[i]}); if ((i%3 >= 2)&&(index < 6)) index = ++index; } var topText = funcTopText(command); topTextGlobal = command; menu_current = menu.reply_markup; if (first_tap) { sendTo('telegram.0', ); first_tap = false; } else { updateMenuButton(user, topText, menu.reply_markup); } } } //************************************ // Команды //************************************ // ищем в тексте команды switch (command) { case "Закрыть": sendTo('telegram', { user: user, deleteMessage: { options: { chat_id: getState("telegram.0.communicate.requestChatId").val, message_id: getState("telegram.0.communicate.requestMessageId").val, } } }); break; } }); function updateMenuButton(user, topText, menu){ sendTo('telegram', { user: user, text: topText, editMessageText: { options: { chat_id: getState("telegram.0.communicate.requestChatId").val, message_id: getState("telegram.0.communicate.requestMessageId").val, parse_mode: 'markdown', reply_markup: menu } } }); } function waitConfirmCommand(obj, command, timeout, ack = false){ var mySubscription; var timeID = setTimeout(() => { unsubscribe(mySubscription); CommandDone('Не выполнено!'); }, timeout); mySubscription = on(, function (data) { // unsubscribe after first trigger if (ack === true) if (data.state.ack) ack = false; if (!ack) { updateMenuButton(user, funcTopText(command), menu_current); unsubscribe(mySubscription); clearTimeout(timeID); CommandDone('Выполнено!'); } }); } function CommandDone(text){ if (text === '') text = "Выполнено!"; sendTo('telegram', { user: user, answerCallbackQuery: { text: text, showAlert: false } }); } function getButtonArray(obj, keyName, Name) { var result = []; for (var attr in obj) { if (obj[attr] && typeof obj[attr] === 'object') { result = result.concat(getButtonArray(obj[attr], keyName, Name)); } if (attr === keyName && obj[attr] === Name) { result.push(obj.button); } } return result; } function stateSelection(state){ if ((state.val === false) || (state.val === 'false')) return "ВЫКЛ. в " + formatDate(state.lc, "SS:mm:ss TT.MM"); if ((state.val === true) || (state.val === 'true')) return "ВКЛ. в " + formatDate(state.lc, "SS:mm:ss TT.MM"); return "неопределено"; } function funcTopText (command){ var text = command; switch (command) { case 'Меню': text = "*Зал*: Температура 24.2 градуса \n" + "*Спальня*: Температура 23.5 градуса \n"; break; } return text; }

Уже на этом этапе можно проверить работу меню. Для этого сохраняем скрипт, запускаем и в Telegram отправляем боту слово Меню (внимание, слово должно быть с большой буквы) или Кнопки

Управление

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

createState("Test.Switch.command", false); // Тестовый выключатель света

Сохраним и команда createState создаст новый объект, который можно посмотреть на вкладке Объекты - javascript.0

Дальше вставим в скрипт команды управления светом на кухне на примере виртуального выключателя

case "Включить": //включить свет на кухне setState("javascript.0.Test.Switch.command"/*Test.Switch.command*/, true); CommandDone('Выполнено!'); break; case "Выключить": //выключить свет на кухне setState("javascript.0.Test.Switch.command"/*Test.Switch.command*/, false); CommandDone('Выполнено!'); break;

Должно получиться так

Ранее в статье был момент, когда названия веток (кнопок) немного менялись и стали отличаться от наброска меню. Весь смысл в том, что названия всех кнопок в меню бота должны быть уникальны, это связано с особенностями API Telegram. Подробнее можно почитать тут. Иначе при нажатии на одинаковые названия кнопок в меню, всегда будут выполняться команды только для какой-то одной кнопки, даже если вы на нее не нажимали.

Разберем подробнее, что же мы сделали:

switch - сравнивает выражение со случаями, перечисленными внутри неё, а затем выполняет соответствующие инструкции. Подробнее тут.

command - будет содержать уникальное имя нажатой кнопки

case "Включить" - сравниваем со всеми описанными в секции switch именами кнопок и ищем команды для нажатой кнопки Выключить.

setState("javascript.0.Test.Switch.command"/*Test.Switch.command*/, true); - записываем значение True в объект Test.Switch.command. Дополнительно по команде setState можно почитать тут.

CommandDone('Выполнено!'); - вызываем функцию CommandDone и передаем ей текст, который хотим отобразить во всплывающем сообщении при нажатии кнопки. В данном случае текст будет Выполнено!

break; - указываем, что выполнение операций для кнопки Включить завершаем. Если не добавить команду break, JS начнет выполнение следующего case.

Для кнопки Выключить все то же самое, только записываемое значение false.

Сохраняем и пробуем! (результат можно увидеть на вкладке Объекты. Будет меняться значение виртуального выключателя). 

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

Красивости

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

function funcTopText (command){ var text = command; switch (command) { case 'Меню': text = "*Зал*: Температура 24.2 градуса \n" + "*Спальня*: Температура 23.5 градуса \n"; break; } return text; }

В функцию автоматически через переменную command передается нажатая кнопка дерева (например Меню, Спальня, Кухня. Свет и т.д.), самый нижний уровень дереве меню передать нельзя (например Кино, Бойлер, Бра и т.д.). И уже в самой функции с помощью уже знакомой конструкции  switch (command) можно добавить вывод необходимой информации о состоянии оборудования или просто какой-то текст для выбранной ветки меню. В примере выше указана ветка Меню, в ней выводим температуры (пока как простой текст) в комнатах Зал и Спальня.

Как сделать форматирование текста жирным или курсивом, можно почитать тут.

Выведем температуру в зале из реального объекта. Надо выбрать из ваших существующих объектов, иначе будет ошибка.

case 'Меню': text = "*Зал*: Температура " + getState("mysensors.0.70.3_TEMP.V_TEMP").val + "°C, \n" + "*Спальня*: Температура 23.5 градуса \n";

Из объекта  mysensors.0.70.3_TEMP.V_TEMP считываем значение температуры и подставляем его в строку (подробнее о команде getState тут).

\n – символ новой строки, является эквивалентом символа перевода строки.

Добавим в меню Кухня отображение состояния виртуального выключателя

function funcTopText (command){ var text = command; switch (command) { case 'Меню': text = "*Зал*: Температура " + getState("mysensors.0.70.3_TEMP.V_TEMP").val + "°C, \n" + "*Спальня*: Температура 23.5 градуса \n"; break; case 'Кухня': text = "*Свет*: состояние: " + getState("Test.Switch.command").val + "\n"; break; } return text; }

Состояние false, как-то скучно…  Да объясняй потом жене, ребенку, друзьям что за false такой…

Используем функцию stateSelection(state), которая будет вместо false/true возвращать нормальный текст Вкл/Выкл. и дополнительно выводить время подачи команды (или любой другой текст на ваше усмотрение)

function stateSelection(state){ if ((state.val === false) || (state.val === 'false')) return "ВЫКЛ. в " + formatDate(state.lc, "SS:mm:ss TT.MM"); if ((state.val === true) || (state.val === 'true')) return "ВКЛ. в " + formatDate(state.lc, "SS:mm:ss TT.MM"); return "неопределено"; } function funcTopText (command){ var text = command; switch (command) { case 'Меню': text = "*Зал*: Температура " + getState("mysensors.0.70.3_TEMP.V_TEMP").val + "°C, \n" + "*Спальня*: Температура 23.5 градуса \n"; break; case 'Кухня': text = "*Свет*: состояние: " + stateSelection(getState("Test.Switch.command")) + "\n"; break; } return text; }

Так гораздо лучше и понятней смотрится!

В функцию stateSelection(state) при необходимости можно добавить другие типы состояний, если они будут выводиться в заголовок меню.

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

function waitConfirmCommand(obj, command, timeout, ack = false){ var mySubscription; var timeID = setTimeout(() => { unsubscribe(mySubscription); CommandDone('Не выполнено!'); }, timeout); mySubscription = on(, function (data) { // unsubscribe after first trigger if (ack === true) if (data.state.ack) ack = false; if (!ack) { updateMenuButton(user, funcTopText(command), menu_current); unsubscribe(mySubscription); clearTimeout(timeID); CommandDone('Выполнено!'); } }); }

Функция подписывается на переданный объект obj и в течение заданного количества миллисекунд timeout ожидает изменения объекта obj. Если событие произошло и была задана дополнительно (но не обязательно) проверка флага ACK, проверяется на условие ack = true. Если совпало или не была задана проверка флага ACK – отобразится текст Выполнено. Если не совпало – Не выполнено. После этого функция отписывается от объекта, чтобы в будущем снова не реагировать на него.

Внесем изменения в скрипт

function funcTopText (command){ var text = command; switch (command) { case 'Меню': text = "*Зал*: Температура " + getState("mysensors.0.70.3_TEMP.V_TEMP").val + "°C, \n" + "*Спальня*: Температура 23.5 градуса \n"; break; case 'Кухня': text = "*Свет*: " + stateSelection(getState("javascript.0.Test.Switch.command")) + "\n"; break; case 'Кухня. Свет': text = "*Свет*: " + stateSelection(getState("javascript.0.Test.Switch.command")) + "\n"; break; } return text; }

case "Включить": //включить свет на кухне setState("javascript.0.Test.Switch.command"/*Test.Switch.command*/, true); waitConfirmCommand("javascript.0.Test.Switch.command", topTextGlobal, 2000, true); break; case "Выключить": //выключить свет на кухне setState("javascript.0.Test.Switch.command"/*Test.Switch.command*/, false); waitConfirmCommand("javascript.0.Test.Switch.command", topTextGlobal, 2000); break;

javascript.0.Test.Switch.command – объект для проверки выполнения команды. Т.к. у нас нет отдельного объекта для обратной связи, используем для этой цели объект-команду.

2000 – время ожидания в миллисекундах

topTextGlobalэту переменную ставим всегда!

Для кнопки Включить добавили проверку флага ACK, для кнопки Выключить нет. Сохраняем и пробуем что получилось.

Уберем проверку флага ACK для кнопки Включить и снова пробуем.

Не забываем смотреть на всплывающие сообщения!

Надеюсь, суть уловили ?

Эмодзи

Пришла очередь добавить символы Эмодзи. Для этого заходим в библиотеку один или два. Выбираем подходящие символы для меню. Возьмем для кнопки Назад следующий символ

Выделяем, копируем и вставим в наш скрипт.

Внимание! Менять надо во всех местах скрипта, где использовано слово Назад.

Замену лучше делать через Поиск комбинацией клавиш ctrl+H

Сохраняем и любуемся результатом

Надеюсь у вас все получилось ?

Итоговый скрипт меню

createState("Test.Switch.command", false); // Тестовый выключатель света var button = [, , , , , , , , , ]; var menuUp = 'Меню'; var first_tap = false; var menu_current; var topTextGlobal; on(, function (obj) { command = obj.state.val.substring(obj.state.val.indexOf(']') + 1); user = obj.state.val.substring(obj.state.val.indexOf('[')+1, obj.state.val.indexOf(']')); //log(command); //log(user); //************************************ // Меню //************************************ if (command ==="/buttons" || command ==="кнопки" || command ==="Кнопки") sendTo('telegram', { user: user, text: 'Показать меню', reply_markup: { keyboard: [['Показать меню']], resize_keyboard: true, one_time_keyboard: true } }); //************************************ // меню inline //************************************ var menu = { reply_markup: { inline_keyboard: [[],[],[],[],[],[],[]], } }; if (command === 'Показать меню') command = menuUp; log (command); if (command === 'Меню') first_tap = true; if (command === '◀️ Назад') command = menuUp; var but1 = getButtonArray(button, 'name', command).toString(); if (but1.length > 0) { // проверяем, что строка не пустая var but2 = but1.split(','); //преобразуем в массив menuUp = but2.pop(); //вырезаем последний элемент if (but2.length > 0) { // проверяем что массив не пуст var index = 0; for (var i=0, len=but2.length; i<len; i++) { menu.reply_markup.inline_keyboard[index].push({ text: but2[i], callback_data: but2[i]}); if ((i%3 >= 2)&&(index < 6)) index = ++index; } var topText = funcTopText(command); topTextGlobal = command; menu_current = menu.reply_markup; if (first_tap) { sendTo('telegram.0', ); first_tap = false; } else { updateMenuButton(user, topText, menu.reply_markup); } } } //************************************ // Команды //************************************ // ищем в тексте команды switch (command) { case "Включить": //включить свет на кухне setState("javascript.0.Test.Switch.command"/*Test.Switch.command*/, true); waitConfirmCommand("javascript.0.Test.Switch.command", topTextGlobal, 2000, true); break; case "Выключить": //выключить свет на кухне setState("javascript.0.Test.Switch.command"/*Test.Switch.command*/, false); waitConfirmCommand("javascript.0.Test.Switch.command", topTextGlobal, 2000); break; case "Закрыть": sendTo('telegram', { user: user, deleteMessage: { options: { chat_id: getState("telegram.0.communicate.requestChatId").val, message_id: getState("telegram.0.communicate.requestMessageId").val, } } }); break; } }); function updateMenuButton(user, topText, menu){ sendTo('telegram', { user: user, text: topText, editMessageText: { options: { chat_id: getState("telegram.0.communicate.requestChatId").val, message_id: getState("telegram.0.communicate.requestMessageId").val, parse_mode: 'markdown', reply_markup: menu } } }); } function waitConfirmCommand(obj, command, timeout, ack = false){ var mySubscription; var timeID = setTimeout(() => { unsubscribe(mySubscription); CommandDone('Не выполнено!'); }, timeout); mySubscription = on(, function (data) { // unsubscribe after first trigger if (ack === true) if (data.state.ack) ack = false; if (!ack) { updateMenuButton(user, funcTopText(command), menu_current); unsubscribe(mySubscription); clearTimeout(timeID); CommandDone('Выполнено!'); } }); } function CommandDone(text){ if (text === '') text = "Выполнено!"; sendTo('telegram', { user: user, answerCallbackQuery: { text: text, showAlert: false } }); } function getButtonArray(obj, keyName, Name) { var result = []; for (var attr in obj) { if (obj[attr] && typeof obj[attr] === 'object') { result = result.concat(getButtonArray(obj[attr], keyName, Name)); } if (attr === keyName && obj[attr] === Name) { result.push(obj.button); } } return result; } function stateSelection(state){ if ((state.val === false) || (state.val === 'false')) return "ВЫКЛ. в " + formatDate(state.lc, "SS:mm:ss TT.MM"); if ((state.val === true) || (state.val === 'true')) return "ВКЛ. в " + formatDate(state.lc, "SS:mm:ss TT.MM"); return "неопределено"; } function funcTopText (command){ var text = command; switch (command) { case 'Меню': text = "*Зал*: ?️ " + getState("mysensors.0.70.3_TEMP.V_TEMP").val + "°C, \n" + "? ? 23.5 градуса \n"; break; case 'Кухня': text = "? " + stateSelection(getState("javascript.0.Test.Switch.command")) + "\n"; break; case 'Кухня ?': text = "? " + stateSelection(getState("javascript.0.Test.Switch.command")) + "\n"; break; } return text; }

Лайфхак

Через @botFather можно добавить команду вызова меню 


👍

много нового для себя исчерпал! 👍

Здравствуйте, очень полезная статья подскажите пожалуйста где именно происходит привязка кнопки бота телеграмм ? например 'Кухня.Свет'
 

'Кухня. Свет'
// Команды
//************************************
// ищем в тексте команды
switch (command) {
case "Включить": //включить свет на кухне
setState("sonoff.0.Zal.POWER1"/*Zal POWER1*/, true);
CommandDone('Выполнено!');
break;

case "Выключить": //включить свет на кухне
setState("sonoff.0.Zal.POWER1"/*Zal POWER1*/, false);
CommandDone('Выполнено!');


Это мой элемент скрипта как правильно привязать кнопку бота  
'Кухня.Свет' к 

"sonoff.0.Zal.POWER1"/*Zal POWER1*/

растолкуйте пожалуйста, как правильно ?



правильно так. При условии что выше в скрипте была описана кнопка для меню 'Кухня. Свет'

Код ниже делает следующее, сначала в условии считывает текущее состояние объекта "sonoff.0.Zal.POWER1", и потом пишет в него противоположную команду

https://sprut.ai/static/media/cache/00/13/75/5/2628632/49744/1000x_image.jpg?1579513067" alt="1000x_image.jpg?1579513067" />

А описание кнопки как у Вас выше в статье правильно ?

{name: 'Кухня', button: ['Кухня. Свет', 'Вентиляция', 'Назад', 'Закрыть', 'Меню']},
{name: 'Верхний свет', button: ['Включить', 'Выключить', 'Назад', 'Закрыть', 'Кухня']},
Скорей всего нет. Не видно картины в целом. И лучше начать с проработки структуры меню

 За основу я взял полностью  Ваш скрипт, но вот как привязать кнопку бота к реальному реле так и не понял.Вы не подскажите где можно посмотреть или почитать ? может есть готовый проект для ознакомления.



в начале статьи есть еще несколько ссылок, где можно почитать. Плюс к этому телеграмм чат https://t.me/iobroker">https://t.me/iobroker

Но если вкратце, то setState("sonoff.0.Zal.POWER1"/*Zal POWER1*/, true); это и есть команда. Записать значение true в объект "sonoff.0.Zal.POWER1", который по идее как раз и должен управлять реальным реле

Еще материалы:

https://github.com/ioBroker/ioBroker">https://github.com/ioBroker/io...

https://github.com/ioBroker/ioBroker.javascript/blob/master/docs/ru/blockly.md">https://github.com/ioBroker/io...

https://github.com/ioBroker/ioBroker.javascript/blob/master/docs/ru/blockly.md">https://github.com/ioBroker/ioBroker.javascript/blob/master/docs/en/javascript.md#setstate">https://github.com/ioBroker/io...

https://github.com/ioBroker/ioBroker.javascript/blob/master/docs/ru/blockly.md">и т.д.

Большое спасибо за информацию, будем изучать.


Вернуться назад
Вернуться назад