Telegram Bots vs Google Forms

Kate

Administrator
Команда форума

Интро​

Google forms отличный инструмент для решения простых организационных задач, например, сбор информации для регистрации людей на небольшое мероприятие. Но что если мы попытаемся решить эту задачу по другому? Например через telegram.

Одна из отличительных особенностей telegram - это, на мой взгляд, простое и удобное bot api. К тому же формат диалога с ботом как нельзя лучше подходит для сбора данных.

Итак, что за задачу мы будем решать? У нас есть ежегодное мероприятие: танцевальный выезд на день рождение студии танцев. Необходимо собрать следующую информацию от участников для организации расселения и планирования разных шумных активностей:

  1. Номер телефона для связи
  2. ФИО
  3. Адрес электронной почты
  4. Пол (пригодится для расселения участников в большие номера)
  5. Категория номера (2-х местный, 3-местный и тд)
  6. Исповедуемое танцевальное направление
  7. Соседи
Для того чтобы не сломать существующий процесс регистрации заполненную заявку мы будем выгружать в гугл таблицы

Технические приготовления​

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

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

О том как зарегистрировать бота, советую обратится к документации telegram - это очень простой и быстрый процесс.

А для сохранения данных в гугл таблицу необходимо будет создать сервисный аккаунт в cloud google platform и предоставить ему доступ к нужной таблице.

А наше приложение будет написано на kotlin + spring-boot + postgresql.

Архитектура решения​

Для хранения данных, как уже упоминалось выше, мы будем использовать самую обычную postgresql со следующей схемой:

create table requests (
id bigint primary key,
user_id bigint,
telegram_login varchar(100),
full_name varchar(30),
email varchar(50),
phone varchar(20),
gender varchar(1),
room_type smallint,
dance_type varchar(20),
neighbors text,
state varchar(20),
insstmp timestamptz not null,
updstmp timestamptz not null
);
Пробежимся по полям таблицы:

  1. Id - уникальный идентификатор запроса в рамках таблицы
  2. user_id - идентификатор пользователя в telegram. Эта информация прилетает к нам в каждом из запросов.
  3. telegram_login - «человекочитаемый» идентификатор пользователя в telegram. Вы можете сами назначить его себе в профиле приложения, а можете и не назначать. Но если он есть скрыть его не получится и он будет прилетать к нам в каждом запросе. И именно по такому логину мы можем написать конкретному человеку, но уже со своего аккаунта. Бот инициировать общение с произвольным незнакомым человеком не может.
  4. full_name - фио участника.
  5. email - адрес электронной почты.
  6. phone - телефон
  7. gender - пол участника
  8. room_type - категория номера. Тут будет номер категории из конфигурации.
  9. dance_type - танцевальное направление
  10. neighbors - список соседи в свободной форме.
  11. state - статус заявки. На статусной модели остановимся подробнее чуть позже.
  12. insstmp - время создания записи.
  13. updstmp - время последнего обновления.
Диалог с ботом в нашем приложении будет строится в формате «вопрос - ответ», что означает при получении ответа от пользователя боту необходимо восстановить контекст предыдущего общения, для этого нам нужна будет определенная статусная модель.

В нашей задаче будут следующие статусы:

START_STATE
PHONE_STATE
FULL_NAME_STATE
MAIL_STATE
GENDER_STATE
ROOM_STATE
DANCESTYLE_STATE
NEIGHBORS_STATE
REQUEST_READY
REQUEST_APPROVED
EXPORTED
Начнём по порядку. Первый контакт участника с ботом случается в тот момент, когда новый человек отправляет команду /start нашему боту на что бот отвечает приветственным сообщением и рассказываем что тут вообще происходит.

Тут мы имеем возможность сохранить публично доступную информацию о пользователе(в нашем случае tg_user_id и telegram_login) в свою бд, как минимум для статистики конверсии.

Статусы с PHONE_STATE по NEIGHBORS_STATE покрывают заполнение соответствующих шагов начиная с номера телефона до выбора соседей по комнате.

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

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

И окончательный статус EXPORTED проставляется в тот момент когда заявка улетела в гугл таблицу.

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

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

Архитектура кода​

Какая же архитектура кода нас ждёт? Тут для меня был интересный челенж. Хотелось выстроить структуру кода приложения в простом и понятном виде, которую легко можно будет расширять и тестировать. Ну в общем ничего нового, простое программистское счастье.

Структуру «вопрос - ответ» я взял за базовый интерфейс:

interface State {
fun ask(userId: Int, chatId: Long, absSender: AbsSender)
fun handle(update: Update, absSender: AbsSender)
}
Как можно понять из названий

  • ask() - метод в котором мы задаём пользователю интересующий нас вопрос
  • handle() - метод для обработки ответа
Ненадолго остановимся на параметрах методов:

userId - идентификатор пользователя в telegram уже упоминаемый ранее

chatId - ещё один идентификатор от telegram, но уже для конкретного чата с пользователем

absSender - это абстракция из библиотеки обертки над telegram bot api, позволяющая отправлять пользователю сообщения и многое другое. В данном приложении я использую вот эту библиотеку: https://github.com/rubenlagus/TelegramBots

update - также абстракция из библиотеки-обертки, содержащая информацию с ответом пользователя, а также всю необходимую мета-информацию о самом пользователе, а именно userId и chatId.

Таким образом на каждый из шагов статусной модели нам необходимо создать отдельный класс имплементирующий интерфейс State.

Для примера давайте рассмотрим заполнение номера телефона.

class PhoneNumberState(
private val messages: MessageService,
private val botRepository: BotRepository,
private val nextState: State
) : State {

override fun ask(userId: Int, chatId: Long, absSender: AbsSender) {
botRepository.execute(SetUserStatus(userId, PHONE_STATE))
absSender.execute(SendMessage(chatId, messages.getMessage("phone_state_ask")))
}

override fun handle(update: Update, absSender: AbsSender) {
val text = update.text ?: ""
if (isValid(text)) {
botRepository.execute(UpdateRequestField(update.userId, Pair("phone", text)))
nextState.ask(update.userId, update.chatId, absSender)
} else {
absSender.execute(
SendMessage(update.chatId,
messages.getMessage("phone_state_validation_error")
)
)
}
}

private fun isValid(text: String): Boolean = text.matches(phoneRegEx)

companion object {
private val phoneRegEx = "(8)\\d{3}\\d{3}-?\\d{4}".toRegex()
}
}
И опять начнём с параметров конструктора:

  • MessageService - класс который вернёт нам сообщение на русском или английском языке в зависимости от локали установленной в конфигурации
  • BotRepository - тонкая обертка над спринговым NamedJdbcTemplate поговорим подробнее о нем чуть позже
  • State - следующий шаг, который будет выполнен после успешного прохождения текущего
Перед тем как задать пользователю вопрос мы устанавливаем соответствующий статус для заявки пользователя и только после этого отправляем сообщение с вопросом.

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

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

Для этого взглянем на конфигурацию. Полностью шаги выполнения сконфигурированны в классе FlowConfiguration. А вот фрагмент конкретно для нашего шага с номером телефона:

@Bean("phoneNumberState")
fun phoneNumberState(
messages: MessageService,
botRepository: BotRepository,
fullNameState: State
): State = PhoneNumberState(messages, botRepository, fullNameState)
А вот фабрика, которая позволяет по userId получить нужный нам бин для обработки

@Component
class UserStateFactory(
private val botRepository: BotRepository,
private val states: Map<String, State>
) {

fun create(userId: Int) =
states[currentUserStateType(userId).beanName]

fun currentUserStateType(userId: Int): StateType =
botRepository.query(CurrentUserState(userId)).firstOrNull() ?: START_STATE
}
Ну а как сама фабрика контактирует с библиотекой-оберткой (уже спринговой) можно наблюдать в классе RegistrationBot

Работа с базой данных​

По задумке приложение должно получиться достаточно легковесным для того чтобы запускать его на разных площадках (heroku, aws, google cloud) без выделение значительных ресурсов. Но при этом приложение основано на spring-boot. Поэтому нужно не затаскивать ничего лишнего. И в данном варианте я решил использовать тонкую самописную обертку над спринговым NamedJdbcTemplate, чтобы отказаться от толстых абстракций при работе с базой.

Вот она:

@Repository
class BotRepository(
private val namedParameterJdbcTemplate: NamedParameterJdbcTemplate
) {
fun execute(specification: ExecSpecification) =
namedParameterJdbcTemplate.update(specification.sql, specification.sqlParameterSource)

fun <T> query(specification: QuerySpecification<T>): List<T> =
namedParameterJdbcTemplate.query(specification.sql, specification.sqlParameterSource, specification.rowMapper)
}
Для взаимодействия с базой у нас есть два метода:

  • execute() - для модификации данных
  • query() - для получения данных
Каждый из методов в качестве параметров принимает тип содержащий информацию о запросе.

interface ExecSpecification {
val sql: String

val sqlParameterSource: Map<String, *>
}

interface QuerySpecification<T> {
val sql: String
val sqlParameterSource: Map<String?, *>
val rowMapper: RowMapper<T>
}
Это позволяет нам собрать в конкретном классе все что может относить к какому-то запросу

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

class SetUserStatus(
private val id: Int?,
private val newState: StateType
) : ExecSpecification {
override val sql: String
get() = "update requests set state = :new_state, updstmp = current_timestamp " +
"where user_id = :user_id and state not in :)final_states)"

override val sqlParameterSource: Map<String, *>
get() =
MapSqlParameterSource()
.addValue("new_state", newState.name)
.addValue("user_id", id)
.addValue("final_states", listOf(REQUEST_APPROVED.name, EXPORTED.name))
.values
}
Вызов:

botRepository.execute(SetUserStatus(userId, PHONE_STATE))

Итог​

На мой взгляд у ботов есть значительное преимущество по сравнению с гугл формами в части возможной автоматизации.

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

Также новые горизонты открываются при работе с постоянными участниками, которые могут регистрироваться на другие мероприятие без однотипного заполнения данных и «в один клик».

И как можно заметить разработка бота требует заметно меньше ресурсов по сравнению например с мобильным приложением.

Ссылка на исходники: https://github.com/dm-aq/registration-bot

Enjoy!

 
Сверху