Inline меню Telegram для бота умного дома ioBroker

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

Предварительные настройки

Для начала убедитесь, что у вас установлены драйвера Telegram и Script Engine.

Так как при увеличении количества написанных скриптов, будет нарастать бардак в дереве, следует сразу приучать себя разбивать скрипты по группам. Добавим новую группу в папку common и назовем ее, например, Telegram. В этой группе в дальнейшем можно будет создавать все скрипты, которые будут относиться к работе с драйвером Telegram.

Вот теперь можно добавить наш будущий скрипт для меню. Для этого надо выделить созданную группу Telegram и нажать кнопку Новый скрипт. В появившемся окне выбираем нужный тип языка — JavaScript.

Поменяем имя на Телеграм бот, и сохраним изменения:

Создание меню

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

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

var button = [{name: 'Меню',button: ['Зал', 'Кухня', 'Спальня', 'Ванная', 'Закрыть', 'Меню']},
                {name: 'Зал',button: ['Люстра', 'Зал. Кондиционер', 'Зал. Сцены', 'Назад', 'Закрыть', 'Меню']},
                    {name: 'Зал. Сцены', button: ['Кино', 'Весь свет', 'Приглушенный свет', 'Назад', 'Закрыть', 'Зал']},
                {name: 'Кухня', button: ['Кухня. Свет', 'Вентиляция', 'Назад', 'Закрыть', 'Меню']},
                    {name: 'Верхний свет', button: ['Включить', 'Выключить', 'Назад', 'Закрыть', 'Кухня']},
                {name: 'Спальня', button: ['Спальня. Свет', 'Спальня. Кондиционер', 'Спальня. Сцены', 'Назад', 'Закрыть', 'Меню']},
                    {name: 'Спальня. Свет', button: ['Верхний', 'Бра', 'Назад', 'Закрыть', 'Спальня']},
                    {name: 'Спальня. Сцены', button: ['Сцена 1', 'Сцена 2', 'Назад', 'Закрыть', 'Спальня']},
                {name: 'Ванная', button: ['Ванная. Свет', 'Бойлер', 'Вытяжка', 'Назад', 'Закрыть', 'Меню']},
    ];

Разберем часть листинга подробнее:

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

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

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

Ориентируясь на объясненную выше часть листинга, добавляем в скрипт весь остальной код:

var button = [{name: 'Меню',button: ['Зал', 'Кухня', 'Спальня', 'Ванная', 'Закрыть', 'Меню']},
                {name: 'Зал',button: ['Люстра', 'Зал. Кондиционер', 'Зал. Сцены', 'Назад', 'Закрыть', 'Меню']},
                    {name: 'Зал. Сцены', button: ['Кино', 'Весь свет', 'Приглушенный свет', 'Назад', 'Закрыть', 'Зал']},
                {name: 'Кухня', button: ['Кухня. Свет', 'Вентиляция', 'Назад', 'Закрыть', 'Меню']},
                    {name: 'Верхний свет', button: ['Включить', 'Выключить', 'Назад', 'Закрыть', 'Кухня']},
                {name: 'Спальня', button: ['Спальня. Свет', 'Спальня. Кондиционер', 'Спальня. Сцены', 'Назад', 'Закрыть', 'Меню']},
                    {name: 'Спальня. Свет', button: ['Верхний', 'Бра', 'Назад', 'Закрыть', 'Спальня']},
                    {name: 'Спальня. Сцены', button: ['Сцена 1', 'Сцена 2', 'Назад', 'Закрыть', 'Спальня']},
                {name: 'Ванная', button: ['Ванная. Свет', 'Бойлер', 'Вытяжка', 'Назад', 'Закрыть', 'Меню']},
    ];

var menuUp = 'Меню';
var first_tap = false;
var menu_current;
var topTextGlobal;

on({id: "telegram.0.communicate.request", change: 'any'}, 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', {user: user, text: topText, parse_mode: 'markdown', reply_markup: menu.reply_markup});
                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({id: obj, change: 'ne'}, 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 — сравнивает выражение со случаями, перечисленными внутри неё, а затем выполняет соответствующие инструкции.
  • case «Включить» — сравниваем со всеми описанными в секции switch именами кнопок и ищем команды для нажатой кнопки Выключить.
  • setState(«javascript.0.Test.Switch.command»/*Test.Switch.command*/, true); — записываем значение True в объект Test.Switch.command.
  • CommandDone(‘Выполнено!’); — вызываем функцию CommandDone и передаем ей текст, который хотим отобразить во всплывающем сообщении при нажатии кнопки. В данном случае текст будет «Выполнено!».
  • break; — указываем, что выполнение операций для кнопки Включить завершаем. Если не добавить команду breakJS начнет выполнение следующего case.
  • Для кнопки Выключить все то же самое, только записываемое значение false.

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

Дизайн

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

За вывод подобной информации отвечает функция funcTopText:

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;
}

Используем функцию 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({id: obj, change: 'ne'}, 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 для кнопки Включить и снова пробуем. Не забываем смотреть на всплывающие сообщения.

Эмодзи

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

Выделяем, копируем и вставим в наш скрипт. Менять надо во всех местах скрипта, где использовано слово Назад. Сохраняем и любуемся результатом:

Итоговый листинг меню:

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


var button = [{name: 'Меню',button: ['Зал', 'Кухня', '?', 'Ванная', 'Закрыть', 'Меню']},
                {name: 'Зал',button: ['Люстра', 'Зал. Кондиционер', 'Зал. Сцены', '◀️ Назад', 'Закрыть', 'Меню']},
                    {name: 'Зал. Сцены', button: ['Кино', 'Весь свет', 'Приглушенный свет', '◀️ Назад', 'Закрыть', 'Зал']},
                {name: 'Кухня', button: ['Кухня ?', 'Вентиляция', '◀️ Назад', 'Закрыть', 'Меню']},
                    {name: 'Кухня ?', button: ['Включить', 'Выключить', '◀️ Назад', 'Закрыть', 'Кухня']},
                {name: '?', button: ['? Спальня. Свет', '? Кондиционер', '? Сцены', '◀️ Назад', 'Закрыть', 'Меню']},
                    {name: '? Свет', button: ['Верхний', 'Бра', '◀️ Назад', 'Закрыть', '?']},
                    {name: '? Сцены', button: ['Сцена 1', 'Сцена 2', '◀️ Назад', 'Закрыть', '?']},
                {name: 'Ванная', button: ['Ванная. Свет', 'Бойлер', 'Вытяжка', '◀️ Назад', 'Закрыть', 'Меню']},
    ];

var menuUp = 'Меню';
var first_tap = false;
var menu_current;
var topTextGlobal;

on({id: "telegram.0.communicate.request", change: 'any'}, 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', {user: user, text: topText, parse_mode: 'markdown', reply_markup: menu.reply_markup});
                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({id: obj, change: 'any'}, 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;
}

Оцените статью
46 - столько SQL запросов к базе.
0,279078 - за столько сгенерировалась страница.