[Из песочницы] Telegram-бот в качестве подарка

Привет, Хабр! Сегодня я хочу рассказать о том, как писал Telegram-бота, да не простого, а подарочного. Прошу под кат тех, кому эта история кажется забавной, а также тех, кто пытается писать своих Telegram-ботов на Java. Возможно, мой небольшой опыт будет в чем-то полезен.

Краткая предыстория


Я работаю программистом чуть более шести лет, пишу в основном на Java и 1С, звезд с неба не хватаю, но поставленные задачи выполняю.

Весной 2017 года меня заинтересовало создание ботов для различных мессенджеров. Сначала хорошей идеей казалось создание бота в Viber. В Сибири он наиболее популярен, в нем сидят практически все знакомые, корпоративные чаты ведутся тоже в нем. Кроме того, вдохновляла эта статья. Однако, создание публичного аккаунта оказалось не такой простой задачей — на все запросы приходили немногословные отказы.

Помучавшись с Вайбером около недели, я обратил внимание на Телеграм, оказалось, что зарегистрировать бота там очень легко, а жизнь джава-программиста облегчается наличием библиотеки TelegramBots. В качестве пробы пера был написан бот, позволяющий получать некоторые отчеты из корпоративных систем, потом был бот со справочной информацией, а осенью уже для себя я попробовал сделать бота для предварительной записи в салон красоты. Все это были интересные поделки, которые нравились окружающим, а первые два бота даже реально использовались в работе. Благодаря этому, в голове постоянно крутились мысли о том, как еще можно применить ботов в быту и народном хозяйстве.

Теперь необходимо сделать лирическое отступление, чтобы рассказать о том, для кого же делался подарок (те, кому интересны только технические подробности, могут смело пропускать).
По работе в качестве программиста на 1С мне много приходится общаться и совместно работать с очень симпатичной коллегой, коллега кроме того, что симпатичная, еще и умна, а временами (когда настроение хорошее) достаточно глубоко вникает в вопросы разработки (а при желании может и запрограммировать что-нибудь) и вообще обладает системным мышлением. А системное мышление — венец женской привлекательности, кто бы спорил. Так вот, со столь привлекательной коллегой мы часто обсуждаем всяческие свистоперделки для 1С (обсуждаем на мою голову, так как потом они попадают в очередные требования к разрабатываемой системе), разные новые технологии и околоайтишные темы. Кстати говоря, своего первого бота, я показывал именно этой коллеге и получил тогда неплохую обратную связь и импульс к изучению темы.

Здесь необходимо заметить, что у коллеги в самом начале декабря намечался день рождения, ну и естественно не поздравить её было нельзя. Достаточно очевидно, что коллеги с системным мышлением в качестве подарков могут рассчитывать не только на конфеты, цветы и прочие ништяки, но и на что-нибудь, претендующее на остроумность. Так в 2016 году в качестве подарка была закастомлена главная страница той системы, которую мы вместе делаем, и в день рождения вместо стандартной заставки коллегу встречало поздравление. Вроде бы мелочь, но в нашей организации никого так не поздравляли и подарок, как говорится, зашел, да так зашел, что коллега посчитала это поздравление самым лучшим в тот день (надеюсь, так и было). После таких достижений было понятно, что в 2017 году надо развить тему и снова дополнить стандартный поздравительный набор чем-нибудь айтишным. В голове смутно бродили мысли о том, что «что-нибудь айтишное» могло бы быть связано с ботами, но четкой идеи не было, и я почти смирился с тем, что ничего оригинального придумать не получится. Время шло, до дня рождения оставалось 5 дней…

Я возвращался с работы домой, пробка текла вяло, и можно было углубиться в свои мысли: планы разработок, технические решения, автоматизация с помощью ботов, предвкушение пятничного пива, в общем, стандартная каша. Вдруг в голове промелькнула мысль, что коллеге все же надо подарить бота, пускай совсем простенького, например, выдающего разные памятные для коллеги фоточки. Мысль показалась совсем несвежей, однако я начал прикидывать, какие фото можно будет использовать. При этом фото было много, а вот обстоятельства, при которых они делались, вспоминались уже с трудом. Тут-то и появилась идея подарочного бота: шуточная викторина, в которой каждый вопрос сопровождался бы фотографиями коллеги и несколькими вариантами ответов, при этом на каждый ответ бот выдавал бы какой-то смешной комментарий. Эту идею я счел с одной стороны достаточно оригинальной, а с другой — осуществимой за оставшееся до дня рождения время.


Подготовка и создание базы вопросов


На выполнение всех работ оставалось всего 4 дня, а если говорить точнее, то 4 вечера, да и то неполных. Под рукой имелись исходники трех других ботов, которые можно использовать как «запчасти», и было понятно, что впереди ждет увлекательное ралли на велосипеде из костылей.
В качестве языка программирования была выбрана Java, в качестве библиотеки, для работы с API Telegram — TelegramBots, для хранения базы вопросов использована СУБД H2.

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

Раскладка фотографий и придумывание вопросов оказались очень трудоемким процессом, и на них ушло полтора вечера.

Далее была реализована база данных, хранящая вопросы. Ниже приведены описание таблиц базы данных и DDL-скрипт.

  • CLS_QUEST — таблица, содержащая тексты вопросов
  • CLS_QUEST_PHOTO — таблица содержащая относительные пути к фотографиям, которые связаны с задаваемым вопросом; сами фотографии лежат в файловой системе в папках, соответствующих вопросу.
  • CLS_ANSWER — таблица, содержащая варианты ответов на вопрос, а также комментарии к каждому варианту ответа


Скрипт
CREATE SCHEMA IF NOT EXISTS QUE;
SET SCHEMA QUE;

CREATE TABLE QUE.CLS_QUEST(
    ID              BIGINT IDENTITY,
    IS_DELETED      INT DEFAULT 0,
    QUEST_TEXT      CLOB
);

CREATE TABLE QUE.CLS_QUEST_PHOTO(
    ID              BIGINT IDENTITY,
    ID_QUEST        BIGINT NOT NULL,
    IS_DELETED      INT DEFAULT 0,
    REL_FILE_PATH   CLOB,
    PHOTO_TEXT      CLOB,
    FOREIGN KEY(ID_QUEST) REFERENCES CLS_QUEST(ID)
);

CREATE TABLE QUE.CLS_ANSWER(
    ID              BIGINT IDENTITY,
    ID_QUEST        BIGINT NOT NULL,
    IS_DELETED      INT DEFAULT 0,
    ANSWER_TEXT     CLOB,
    ANSWER_COMMENT  CLOB,
    FOREIGN KEY(ID_QUEST) REFERENCES CLS_QUEST(ID)
);


После создания база была наполнена данными вручную, благо в Netbeans, который я использую как среду разработки, достаточно удобный редактор SQL-скриптов.

По истечении двух дней база вопросов и фотографий была готова, времени оставалось совсем немного, пора было переходить к созданию самого бота.

Каркас бота


Напомню, что для создания бота в Telegram необходимо написать @BotFather, пользуясь командой /newbot ввести для бота отображаемое имя и имя пользователя. После выполнения этих действий будет получен токен для доступа к API Telegram. Выглядит это примерно так.

91otxrvq4l_i7o9ew7qkwislb28.png


Для красоты добавим фото профиля с помощью /setuserpic.

upggej05ebnnswwb8cfs3syp2xq.png


Теперь перейдем к созданию самого бота с помощью TelegramBots. Напомню, что Telegram позволяет создавать боты работающие с Webhooks и LongPolling-боты. Выбран был второй вариант. Для создания LongPolling-бота, необходимо реализовать собственный класс, наследующий классу org.telegram.telegrambots.bots.TelegramLongPollingBot.

Исходный код класса
public class Bot extends TelegramLongPollingBot {

    private static final String TOKEN = "TOKEN";
    private static final String USERNAME = "USERNAME";

    public Bot() {
    }

    public Bot(DefaultBotOptions options) {
        super(options);
    }

    @Override
    public String getBotToken() {
        return TOKEN;
    }

    @Override
    public String getBotUsername() {
        return USERNAME;
    }

    @Override
    public void onUpdateReceived(Update update) {
        if (update.hasMessage() && update.getMessage().hasText()) {
            processCommand(update);
        } else if (update.hasCallbackQuery()) {
            processCallbackQuery(update);
        } 
    }
}


TOKEN — токен для доступа к API Telegram, полученный на этапе регистрации бота.
USERNAME — имя бота, полученное на этапе регистрации бота.
Метод onUpdateReceived вызывается при поступлении боту «входящих обновлений». В нашем боте нас интересует обработка текстовых команд (если быть честным, то только команды /start) и обработка колбэков (обратных вызовов), возникающих при нажатии на кнопки инлайн-клавиатуры (размещается в области сообщений).

Бот проверяет, является ли входящее обновление текстовым сообщением update.hasMessage() && update.getMessage().hasText() или колбэком update.hasCallbackQuery(), после чего вызывает соответствующие методы для обработки. О содержимом этих методов поговорим немного позже.

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

Исходный код main-класса

public class Main {
    public static void main(String[] args) {
	ApiContextInitializer.init();
	TelegramBotsApi botsApi = new TelegramBotsApi();

        Runnable r = () -> {
            Bot bot = null;
            HttpHost proxy = AppEnv.getContext().getProxy();
            if (proxy == null) {
                bot = new Bot();
            } else {
                DefaultBotOptions instance = ApiContext
                        .getInstance(DefaultBotOptions.class);
                RequestConfig rc = RequestConfig.custom()
                        .setProxy(proxy).build();
                instance.setRequestConfig(rc);
                bot = new Bot(instance);
            }

            try {
                botsApi.registerBot(bot);
                AppEnv.getContext().getMenuManager().setBot(bot);
            } catch (TelegramApiRequestException ex) {
                Logger.getLogger(Main.class.getName())
                        .log(Level.SEVERE, null, ex);
            }
        };

        r.run();

        while (true) {
            try {
                Thread.sleep(80000L);
            } catch (InterruptedException ex) {
                Logger.getLogger(Main.class.getName())
                        .log(Level.SEVERE, null, ex);
            }
        }
    }
}


Ничего сложного в инициализации бота нет, однако хочу обратить внимание, что достаточно важно предусмотреть возможность указать боту прокси. В нашем случае настройки прокси хранятся в обычном properties-файле, откуда считываются при старте программы. Также замечу, что в приложении используется собственный нехороший велосипед в виде некоего подобия глобального контекста AppEnv.getContext(). На момент написания бота исправлять это было некогда, но в новых «поделках» удалось изжить этот велосипед и использовать вместо него Google Guice.

Приветственное сообщение


Работа бота естественно начинается с обработки команды /start. Как было написано выше, эта команда обрабатывается методом processCommand.
В начале метода объявим смайлы, которые будут использоваться в тексте приветственного сообщения.

final String smiling_face_with_heart_eyes = 
    new String(Character.toChars(0x1F60D));
final String winking_face = new String(Character.toChars(0x1F609));
final String bouquet = new String(Character.toChars(0x1F490));
final String party_popper = new String(Character.toChars(0x1F389));


Далее производится проверка введенной команды и если это команда /start, то формируется ответное сообщение answerMessage. У сообщения устанавливается текст setText(), включается поддержка некоторых html-теговsetParseMode("HTML") и устанавливается идентификатор чата, в который сообщение будет отправлено setChatId(update.getMessage().getChatId()). Осталось только добавить кнопку «Начать». Для этого сформируем инлайн-клавиатуру и добавим ее в ответ:

SendMessage answerMessage = null;
String text = update.getMessage().getText();
if ("/start".equalsIgnoreCase(text)) {
    answerMessage = new SendMessage();
    answerMessage.setText("Привет!" + smiling_face_with_heart_eyes + 
         "\nВо-первых с днем рождения!" 
          + bouquet + bouquet + bouquet + party_popper
          + " А во-вторых, ты готова поиграть в увлекательную викторину?");
    answerMessage.setParseMode("HTML");
    answerMessage.setChatId(update.getMessage().getChatId());
    InlineKeyboardMarkup markup = keyboard(update);
    answerMessage.setReplyMarkup(markup);
}


Исходный код формирования клавиатуры приведен ниже:
private InlineKeyboardMarkup keyboard(Update update) {
    final InlineKeyboardMarkup markup = new InlineKeyboardMarkup();
    List> keyboard = new ArrayList<>();
    keyboard.add(Arrays.asList(buttonMain()));
    markup.setKeyboard(keyboard);
    return markup;
}

private InlineKeyboardButton buttonMain() {
    final String OPEN_MAIN = "OM";
    final String winking_face = new String(Character.toChars(0x1F609));
    InlineKeyboardButton button = new InlineKeyboardButtonBuilder()
        .setText("Начать!" + winking_face)
        .setCallbackData(new ActionBuilder(marshaller)
            .setName(OPEN_MAIN)
            .asString())
         .build();
    return button;
}


Интересным моментом является установка данных колбэка. Эти данные могут использоваться при обработке нажатия кнопок. В нашем случае в данные колбэка записывается сериализованный в JSON объект. Этот способ тяжеловесен для данной задачи, но позволяет работать с данными возврата без лишних заморочек на преобразование. Данные возврата формируются в специальном билдере ActionBuilder.

Исходный код ActionBuilder
public class Action {

    protected String name = "";
    protected String id = "";
    protected String value = "";

    public String getName() {
        return name;
    }

    public void setName(String value) {
        this.name = value;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }

}
public class ActionBuilder {

    private final DocumentMarshaller marshaller;
    private Action action = new Action();

    public ActionBuilder(DocumentMarshaller marshaller) {
        this. marshaller = marshaller;
    }

    public ActionBuilder setName(String name) {
        action.setName(name);
        return this;
    }

    public ActionBuilder setValue(String name) {
        action.setValue(name);
        return this;
    }

    public String asString() {
        return marshaller.marshal(action, "Action");
    }

    public Action build() {
        return action;
    }

    public Action build(Update update) {
        String data = update.getCallbackQuery().getData();
        if (data == null) {
            return null;
        }

        action = marshaller.unmarshal(data, "Action");

        if (action == null) {
            return null;
        }
        return action;
    }
}


Для того, чтобы ActionBuilder мог вернуть JSON ему необходимо передать маршаллер. Здесь и далее при упоминании переменной marshaller подразумевается, что она является объектом класса, реализующего интерфейс DocumentMarshaller.

Исходный код DocumentMarshaller

public interface DocumentMarshaller {
	
     String marshal(T document);
		
     T unmarshal(String str);
        
     T unmarshal(String str, Class clazz);
}


Маршаллер, который используется в ActionBuilder, реализован с использованием Jackson.

И, наконец, производится отправка сообщения:

try {
    if (answerMessage != null) {
        execute(answerMessage);
    }
} catch (TelegramApiException ex) {
     Logger.getLogger(Bot.class.getName())
        .log(Level.SEVERE, null, ex);
} 


В итоге, приветственное сообщение выглядит вот так.

qnkegk1_ixo1jg_awep_oqbfqfc.png


Задаем вопросы


Оставалось сделать самое интересное — реализовать логику работы бота.
Для работы с базой вопросов использовалось JPA. Приведу код классов-сущностей.

Исходный код классов-сущностей
public abstract class Classifier implements Serializable {

    private static final long serialVersionUID = 1L;

    public Classifier() {
    }

    public abstract Long getId();

    public abstract Integer getIsDeleted();

    public abstract void setIsDeleted(Integer isDeleted);

}

@Entity
@Table(name = "CLS_ANSWER", catalog = "QUEB", schema = "QUE")
@XmlRootElement
public class ClsAnswer extends Classifier implements Serializable {

    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Basic(optional = false)
    @Column(name = "ID")
    private Long id;
    @Column(name = "IS_DELETED")
    private Integer isDeleted;
    @Lob
    @Column(name = "ANSWER_TEXT")
    private String answerText;
    @Lob
    @Column(name = "ANSWER_COMMENT")
    private String answerComment;
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "idAnswer")
    private Collection regQuestAnswerCollection;
    @JoinColumn(name = "ID_QUEST", referencedColumnName = "ID")
    @ManyToOne(optional = false)
    private ClsQuest idQuest;

    public ClsAnswer() {
    }

    public ClsAnswer(Long id) {
        this.id = id;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Integer getIsDeleted() {
        return isDeleted;
    }

    public void setIsDeleted(Integer isDeleted) {
        this.isDeleted = isDeleted;
    }

    public String getAnswerText() {
        return answerText;
    }

    public void setAnswerText(String answerText) {
        this.answerText = answerText;
    }

    public String getAnswerComment() {
        return answerComment;
    }

    public void setAnswerComment(String answerComment) {
        this.answerComment = answerComment;
    }

    @XmlTransient
    public Collection getRegQuestAnswerCollection() {
        return regQuestAnswerCollection;
    }

    public void setRegQuestAnswerCollection(Collection regQuestAnswerCollection) {
        this.regQuestAnswerCollection = regQuestAnswerCollection;
    }

    public ClsQuest getIdQuest() {
        return idQuest;
    }

    public void setIdQuest(ClsQuest idQuest) {
        this.idQuest = idQuest;
    }
} 
@Entity
@Table(name = "CLS_QUEST", catalog = "QUEB", schema = "QUE")
@XmlRootElement
public class ClsQuest extends Classifier implements Serializable {

    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Basic(optional = false)
    @Column(name = "ID")
    private Long id;
    @Column(name = "IS_DELETED")
    private Integer isDeleted;
    @Lob
    @Column(name = "QUEST_TEXT")
    private String questText;
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest")
    private Collection regQuestAnswerCollection;
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest")
    private Collection clsAnswerCollection;
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest")
    private Collection clsQuestPhotoCollection;

    public ClsQuest() {
    }

    public ClsQuest(Long id) {
        this.id = id;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Integer getIsDeleted() {
        return isDeleted;
    }

    public void setIsDeleted(Integer isDeleted) {
        this.isDeleted = isDeleted;
    }

    public String getQuestText() {
        return questText;
    }

    public void setQuestText(String questText) {
        this.questText = questText;
    }

    @XmlTransient
    public Collection getRegQuestAnswerCollection() {
        return regQuestAnswerCollection;
    }

    public void setRegQuestAnswerCollection(Collection regQuestAnswerCollection) {
        this.regQuestAnswerCollection = regQuestAnswerCollection;
    }

    @XmlTransient
    public Collection getClsAnswerCollection() {
        return clsAnswerCollection;
    }

    public void setClsAnswerCollection(Collection clsAnswerCollection) {
        this.clsAnswerCollection = clsAnswerCollection;
    }

    @XmlTransient
    public Collection getClsQuestPhotoCollection() {
        return clsQuestPhotoCollection;
    }

    public void setClsQuestPhotoCollection(Collection clsQuestPhotoCollection) {
        this.clsQuestPhotoCollection = clsQuestPhotoCollection;
    }
}
@Entity
@Table(name = "CLS_QUEST_PHOTO", catalog = "QUEB", schema = "QUE")
@XmlRootElement
public class ClsQuestPhoto extends Classifier implements Serializable {

    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Basic(optional = false)
    @Column(name = "ID")
    private Long id;
    @Column(name = "IS_DELETED")
    private Integer isDeleted;
    @Lob
    @Column(name = "REL_FILE_PATH")
    private String relFilePath;
    @Lob
    @Column(name = "PHOTO_TEXT")
    private String photoText;
    @JoinColumn(name = "ID_QUEST", referencedColumnName = "ID")
    @ManyToOne(optional = false)
    private ClsQuest idQuest;

    public ClsQuestPhoto() {
    }

    public ClsQuestPhoto(Long id) {
        this.id = id;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Integer getIsDeleted() {
        return isDeleted;
    }

    public void setIsDeleted(Integer isDeleted) {
        this.isDeleted = isDeleted;
    }

    public String getRelFilePath() {
        return relFilePath;
    }

    public void setRelFilePath(String relFilePath) {
        this.relFilePath = relFilePath;
    }

    public String getPhotoText() {
        return photoText;
    }

    public void setPhotoText(String photoText) {
        this.photoText = photoText;
    }

    public ClsQuest getIdQuest() {
        return idQuest;
    }

    public void setIdQuest(ClsQuest idQuest) {
        this.idQuest = idQuest;
    }
}


Также замечу, что здесь и далее для доступа к данным используется объект, реализующий интерфейс ClassifierRepository, а при упоминании переменной classifierRepository подразумевается, что она является объектом класса, реализующего интерфейс ClassifierRepository

Исходный код ClassifierRepository
public interface ClassifierRepository {
     void add(T classifier);
     List find(Class clazz);    
     T find(Class clazz, Long id);   
     List find(Class clazz, boolean isDeleted);
     List getAll(Class clazz);   
     List getAll(Class clazz, boolean isDeleted);
}


Теперь перейдем к тому моменту, когда нажимается кнопка «Начать!». В этот самый момент бот обрабатывает очередную порцию входящей информации и вызывает ранее упоминавшийся метод processCallbackQuery(). В начале метода обрабатывается входящее обновление, а также извлекаются данные колбэка. На основании данных колбэка определяется, было ли произведено нажатие на кнопку «Начать!» OPEN_MAIN.equals(action.getName(), либо была нажата кнопка ответа на очередной вопрос. GET_ANSWER.equals(action.getName()).

final String OPEN_MAIN = "OM";
final String GET_ANSWER = "GA";
Action action = new ActionBuilder(marshaller).buld(update);
String data = update.getCallbackQuery().getData();
Long chatId = update.getCallbackQuery().getMessage().getChatId();


Если викторина только начата, то необходимо проинициализировать список вопросов и задать первый вопрос.

 if (OPEN_MAIN.equals(action.getName())) {
     initQuests(update);
     sendQuest(update);
}


Сейчас рассмотрим инициализацию списка вопросов initQuests():

private void initQuests(Update update) {
    QuestStateHolder questStateHolder = new   QuestStateHolder();
    List q = classifierRepository.find(ClsQuest.class, false);
    Collections.shuffle(q);
    questStateHolder.put(update, new QuestEnumeration(q));
}


В методе initQuestsсначала получим все 26 вопросов, а потом перемешаем в случайном порядке. После этого вопросы положим в QuestEnumeration, откуда будем получать их по одному, до тех пор, пока не будут получены все 26 вопросов. QuestEnumeration добавим в объект специального класса QuestStateHolder, хранящего соответствие пользователя и его текущей сессии вопросов. Код классов QuestStateHolder и QuestEnumeration ниже.

Исходный код QuestStateHolder и QuestEnumeration
public class QuestStateHolder{
    private Map questStates = new HashMap<>();   
    
    public QuestEnumeration get(User user) {
        return questStates.get(user.getId()) == null ? null : questStates.get(user.getId());
    }
    
    public QuestEnumeration get(Update update) {
        User u = getUserFromUpdate(update);
        return get(u);
    }
    
    public void put(Update update, QuestEnumeration questEnumeration) {
        User u = getUserFromUpdate(update);
        put(u, questEnumeration);
    }
    
    public void put(User user, QuestEnumeration questEnumeration) {
        questStates.put(user.getId(), questEnumeration);
    }
	
	static User getUserFromUpdate(Update update) {
        return update.getMessage() != null ? update.getMessage().getFrom()
                : update.getCallbackQuery().getFrom();
    }

}

public class QuestEnumeration implements Enumeration{
    private List quests = new ArrayList<>();
    private Integer currentQuest = 0;
    
    public QuestEnumeration(List quests){
        this.quests.addAll(quests);
    }

    @Override
    public boolean hasMoreElements() {
        return currentQuest < quests.size();
    }

    @Override
    public ClsQuest nextElement() {
        ClsQuest q = null;
        if (hasMoreElements()){
            q = quests.get(currentQuest);
            currentQuest++;
        }
        return q;
    }
    
    public Integer getCurrentQuest(){
        return currentQuest;
    }
}


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

Long answId = Long.parseLong(action.getValue());
ClsAnswer answ = classifierRepository.find(ClsAnswer.class, answId);


Потом готовим на основе найденного ответа сообщение и отправляем его:

SendMessage comment = new SendMessage();
comment.setParseMode("HTML");
comment.setText("Твой ответ: "
    + answ.getAnswerText()
    + "\nКомментарий к ответу: "
    + answ.getAnswerComment() + "\n");
comment.setChatId(chatId);
execute(comment);


Теперь рассмотрим метод sendQuest, который отправляет очередной вопрос. Начинается все с получения очередного вопроса:

QuestEnumeration qe = questStateHolder.get(update);
ClsQuest nextQuest = qe.nextElement();


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

Long chatId = update.getCallbackQuery().getMessage().getChatId();
SendMessage quest = new SendMessage();
quest.setParseMode("HTML");
quest.setText("Вопрос " + qe.getCurrentQuest() + ":  " 
    + nextQuest.getQuestText());
quest.setChatId(chatId);
execute(quest);


Теперь отправляем фотографии, относящиеся к данному вопросу:

for (ClsQuestPhoto clsQuestPhoto : nextQuest.getClsQuestPhotoCollection()) {
    SendPhoto sendPhoto = new SendPhoto();
    sendPhoto.setChatId(chatId);
    sendPhoto.setNewPhoto(new File("\\photo" + clsQuestPhoto.getRelFilePath()));
    sendPhoto(sendPhoto);
}


И, наконец, варианты ответа:

SendMessage answers = new SendMessage();
answers.setParseMode("HTML");
answers.setText("Варианты ответа:");
answers.setChatId(chatId);
answers.setReplyMarkup(keyboardAnswer(update, nextQuest));
execute(answers);


Клавиатура с вариантами ответа формируется следующим образом

Исходный код
private InlineKeyboardMarkup keyboardAnswer(Update update, ClsQuest quest) {
    final InlineKeyboardMarkup markup = new InlineKeyboardMarkup();
    List> keyboard = new ArrayList<>();
    for (ClsAnswer clsAnswer : quest.getClsAnswerCollection()) {
        keyboard.add(Arrays.asList(buttonAnswer(clsAnswer)));
    }
    markup.setKeyboard(keyboard);
    return markup;
}
private InlineKeyboardButton buttonAnswer(ClsAnswer clsAnswer) {
    InlineKeyboardButton button = new InlineKeyboardButtonBuilder()
        .setText(clsAnswer.getAnswerText())
        .setCallbackData(new ActionBuilder(marshaller)
                .setName(GET_ANSWER)
                .setValue(clsAnswer.getId().toString())
                .asString())
        .build();
    return button;
}


Вопрос отправлен. Выглядеть это будет примерно так (фотографии немного размыты, чтобы никого не смущать).

ziauc08snt-hyyvqj-irbohnc8s.png


В тот момент, когда вопросы закончились, будет сформировано сообщение об окончании викторины:

SendMessage answers = new SendMessage();
answers.setParseMode("HTML");
answers.setText("Ну вот и все! Подробности на процедуре награждения \n "
    + "Если хочешь начать заново нажми кнопку 'Начать' или введи /start");
answers.setChatId(chatId);
execute(answers);


И в самом конце отправим забавный стикер:

SendSticker sticker = new SendSticker();
sticker.setChatId(chatId);           
File stikerFile = new File("\\photo\\stiker.png");
sticker.setNewSticker(stikerFile);
sendSticker(sticker);


wtbhrdapjhci9zvcazyk9ckh3ok.png


На этом работа бота завершается.

Работа над всей описанной функциональностью была завершена где-то в 11 часов вечера дня, предшествующего дню X. Замечу, что имея в виду некоторые особенности, я понимал, что запустить бота надо ровно в 12 ночи. В связи с этим я испытывал некоторый цейтнот (почему и забыл про историю ответов). Кроме того коллегу необходимо было как-то оповестить об этой викторине. По ряду причин просто скинуть ссылку я не мог, поэтому доверил оповещение другому боту (благо в процессе тестирования идентификатор пользователя был сохранен и бот мог свободно писать). На этом история написания бота заканчивается.

Заключение


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

Также хочу заметить, что сама идея ботов очень перспективна. Есть множество повседневных задач от заказа пиццы до вызова такси, которые предлагается решать посредством мобильных приложений, сайтов и их мобильных версий или телефонных звонков оператору. С одной стороны все эти способы доказали свою эффективность, удобны и в ближайшее время вряд ли изменятся. С другой стороны приложения для мобильных устройств хотя и богаты функциональностью, но требуют установки, обновления и изучения их интерфейса, а еще имеют свойство кушать батарею. Сайты и их мобильные версии требуют от пользователя как минимум перехода в браузер и работы с новым интерфейсом, что не всегда удобно, особенно на мобильных устройствах. Взаимодействие по телефону удобно для многих, но не подразумевает какой-либо визуализации в принципе, а кроме того оператор всегда будет узким местом системы. При решении тех же задач боты не требуют установки чего-либо кроме мессендежера, не требуют изучения новых интерфейсов и позволяют пользователю работать асинхронно (в отличие от телефонного звонка) в относительно знакомом интерфейсе мессенджера. Мессенджер в таком случае предоставляет некую среду для клиент-серверного взаимодействия, где в роли сервера выступает бот, а клиентская часть реализована средствами мессенджера. Безусловно, при работе с ботами для пользователя также возникают различные трудности, в чем-то схожие с трудностями работы с текстовыми интерфейсами, в чем-то обусловленные ограничениями самих мессенджеров. Но все равно боты, ориентированные на решение небольших повседневных задач (или на развлечения, как бот, описанный в этой статье) представляются перспективными.

Этой мой первый пост, буду признателен за конструктивную обратную связь!

© Habrahabr.ru