суббота, 29 октября 2016 г.

Закусочная Боба или Facebook Chatbot на Spring Boot

Год назад мы с другом покупали хот-доги в закусочной в холле офиса. Алгоритм такой: прийти заказать хот-дог, потом в зависимости от нагрузки вернуться через 10-15 мин. Мы еще тогда шутили, что надо просто делать заказ через ФБ и приходить после того как они напишут, что хот-дог готов.

Это было как раз перед запуском бот-платформы у ФБ. После запуска платформы собирался написать приложение, чтобы познакомиться с технологией. Решил что идея с ботом для закусочной подойдет. Для запуска бота нужны 3 составляющие ФБ приложение, создать публичную страницу, добавить поддержку бота в приложении связав со страницей.
В итоге мы подготовили все необходимые параметры. Передадим их приложению через переменные среды. Само приложение представляет из себя REST сервис на Spring Boot. На фейсбуке, в настройках приложения настроиваем webhook указав callback URL и Verify Token.
Теперь остановимся подробнее на контроллере:

@RestController
public class WebhookController {

    [...]

    @RequestMapping(value = "/webhook", method = RequestMethod.GET)
    public ResponseEntity getWebhook(@RequestParam("hub.verify_token") Optional verifyToken, @RequestParam("hub.mode") Optional mode,
                                        @RequestParam("hub.challenge") Optional challenge) {

        if (mode.filter(SUBSCRIBE::equals).isPresent()
                && verifyToken.filter(validationToken::equals).isPresent()
                && challenge.isPresent()) {
            return ResponseEntity.ok(challenge.get());
        } else {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }

    }

    @RequestMapping(value = "/webhook", method = RequestMethod.POST)
    public ResponseEntity postWebhook(@RequestBody String request) {
        messageService.processMessage(request);
        return ResponseEntity.ok().build();
    }

    [...]
}
Мы видим два метода, которые обрабатывают GET и POST соотвественно. Первый используется для настройки подписки на бот, а второй для получения от него сообщений согласно этой подписке. Устанавливая подписку мы выбираем типы callback'ов:
На сервер приходит запрос с кодом проверки, сравниваем код и даем ответ Http Status OK (200). Подписка закончена, обрабатываем callback'и.
Что нам придет в колбэках зависит от того как мы построим диалог с пользователем. Пообщавшись с ботом CNN, я построил бот таким образом чтобы мне приходили postback'и. Их структура:

{
  "sender":{
    "id":"USER_ID"
  },
  "recipient":{
    "id":"PAGE_ID"
  },
  "timestamp":1458692752478,
  "postback":{
    "payload":"USER_DEFINED_PAYLOAD"
  }
}    
В зависимости от того какой постбэк мы получили (объект postback поле payload) формируется ответ.
Для анмаршалинга я использовал JsonPath, потому что он работает со структурой json без приведения к конкретному классу. Ответ отсылается через RestTemplate и соответственно для маршаллинга используется Jackson ObjectMapper:
Теперь остановимся на самом диалоге. Он должен начинаться с приветствия:
Лучше поместить туда информацию которая поможет пользователю в этом. С помощью кнопки Начать начинаем переписку.
Сам диалог я построил посредством структурированных сообщений (шаблон кнопки и общий шаблон). Нажатие на кнопку отрпавляет сообщение с постбэком, в ответ на которое мы получаем новое структурированное сообщение. В итоге мы управляем диалогом с пользователем предлагая ему опции в зависимости от выбора. Схема диалога выглядит так:

В коде эта цепочка выглядит так:

//принимаем запрос
@RestController
public class WebhookController {
[...]
    @RequestMapping(value = "/webhook", method = RequestMethod.POST)
    public ResponseEntity postWebhook(@RequestBody String request) {
        messageService.processMessage(request);
        [...]
    }
[...]
//передаем в MessageService
@Service
public class MessageService {
    //определяем тип запроса
    public void processMessage(String request) {
       List> entry = JsonPath.read(request, $_ENTRY);
        entry.forEach(e ->
                ((List>) e.get("messaging")).
                        forEach(m -> {
                            [...]
                            } else if (isMessageTypeOf(m, POSTBACK)) {
                                receivedPostback(m);
       [...]
    }
    //обрабатываем постбэк, формируем ответное сообщение
    private void receivedPostback(Map m) {
        [...]
        MessageBuilder messageBuilder =
                new MessageBuilder(messageSource, cache, getLocale(senderId));
        MessageTemplate message =
                messageBuilder.buildMessageAfterPostback(postback, senderId);
        callSenderApi(message);
    }
    //отправляем ответ через рест темплейт
    public void callSenderApi(MessageTemplate message) {
        restTemplate.postForObject(
                "https://graph.facebook.com/v2.6/me/messages?access_token=" + pageAccessToken,
                message, String.class);
    }
}
//класс, который формирует ответные сообщения
public class MessageBuilder {
[...]

    //в этом методе находится вся логика диалога
    public MessageTemplate buildMessageAfterPostback(String postback, Long senderId) {
        if (MENU_PAYLOAD.equals(postback)) {
            return templateBuilder.getMenuTemplate(senderId);
        } else if (MAIN_MENU_PAYLOAD.equals(postback)) {
            return templateBuilder.getMainMenuTemplate(senderId);
        } else if (ORDER_MEXICO.equals(postback)) {
            addMexicoToShoppingCart(senderId, cache);
            return templateBuilder.getNextStepMenu(senderId);
        } else if (ORDER_NAPLES.equals(postback)) {
            addNaplesToShoppingCart(senderId, cache);
            return templateBuilder.getNextStepMenu(senderId);
        } else if (FINISH_ORDER.equals(postback)) {
            ShoppingCart shoppingCart = getShoppingCart(senderId, cache);
            return new TextMessageTemplate(senderId,
                    getMessage(ORDER_AMOUNT_FINAL, new Object[] {getSubtotalThenClearCart(shoppingCart)}));
        } else if (VIEW_ORDER.equals(postback)) {
            ShoppingCart shoppingCart = getShoppingCart(senderId, cache);
            String orderInfo = getOrderInfo(shoppingCart);
            return templateBuilder.getOrderViewedMenu(senderId, orderInfo);
        } else if (CANCEL_ORDER.equals(postback)) {
            clearShoppingCart(senderId, cache);
            return templateBuilder.getMainMenuTemplate(senderId);
        }
        return new TextMessageTemplate(senderId, POSTBACK_RECEIVED);
    }
[...]
}

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

    @Bean
    public Cache cache() {
        return CacheBuilder.newBuilder()
                .concurrencyLevel(4)
                .maximumSize(10000)
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .build();
    }
настроив размер и время хранения в кэше.
Так же для бота можно настроить интернационализацию, получив данные о пользователе, в том числе локаль:
https://graph.facebook.com/v2.6/{id}?fields=first_name,last_name,locale,timezone,gender&access_token={accessToken}

Код находится здесь
C ботом можно пообщаться здесь

1 комментарий:

  1. Aussie casino site offering a wide variety of slots - Lucky Club
    Aussie casino site offering a wide range of slots · Big Rhino casino · luckyclub.live Big Cat casino · Big Red casino · Big Safari casino · Play Slots casino · Play Slots casino · Slot

    ОтветитьУдалить