Lego Party — это моя подработка — развлечения на тему Lego, такие как вечеринки, уроки, пьяные конструкторы Lego, терапия и многое другое! Моя последняя статья перенесла этот бизнес в 21 век (и позволила мне перестать отвечать на звонки во время моей основной работы), когда мы создали онлайн-календарь занятий с помощью Acuity Scheduling.

Люди находят бизнес в Интернете. Вряд ли это новость, но все чаще недостаточно просто быть в сети. Компании должны встречать клиентов на своей территории. Сегодня Lego Party делает следующий шаг и предоставляет онлайн-планирование непосредственно людям на Facebook с помощью API планирования встреч Acuity Scheduling. Нам также понадобится диалоговая платформа ИИ, такая как Init.ai, Wit и API, и это лишь некоторые из них. Для этого проекта я выбрал разговорный API Init.ai из-за их хорошего SDK и прямого подключения к Facebook Messenger.

Оглавление:

  1. Обзор
  2. Расписание остроты
  3. Инит.ай
  4. "Приложение"
  5. "Вывод"

Обзор

Мы создадим диалогового чат-бота для страницы Lego Party в Facebook, чтобы выполнять две наиболее распространенные задачи планирования для клиентов:

  1. Бронирование новых классов
  2. Проверка забронированных занятий

Нам понадобится как учетная запись Acuity Scheduling, так и учетная запись Init.ai (кредитные карты не требуются!). Acuity Scheduling — это онлайн-планировщик встреч со всеми прибамбасами, необходимыми Lego Party, а также удобным для разработчиков API планирования для проверки предстоящих занятий и создания новых заказов. Init.ai — это платформа для разработки диалоговых приложений на основе обработки естественного языка с прямой интеграцией в Facebook Messenger.

Само приложение будет состоять из одного серверного компонента, написанного на Node.js с использованием SDK Acuity, SDK Init.ai, Moment.js и образца приложения от Init.ai. И мы будем строить его, чтобы сделать это:

Начнем!

Расписание остроты

Начните работу с Acuity Scheduling, зарегистрировавшись на бесплатную пробную версию здесь. Не стесняйтесь ковыряться! Но для этого проекта нам понадобятся несколько занятий, так что не уходите без создания пары типов встреч «Класс» и обязательно предложите несколько занятий для каждого занятия. Вот расписание предстоящих занятий, которое люди видят, когда посещают страницу расписания моих клиентов:

После того, как вы настроите свои классы, взгляните на API планирования, которые мы будем интегрировать:

Инит.ай

Init.ai находится в бесплатной бета-версии — для регистрации требуется только учетная запись GitHub. После регистрации создайте новый проект для Занятий. Сложность работы с Init.ai прекрасно абстрагируется их SDK, поэтому я не буду здесь заострять на этом внимание. Вместо этого есть несколько ключевых концепций, которые следует учитывать при реализации:

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

Первым шагом в новом проекте является создание языка обучения — чем больше, тем лучше! Init.ai имеет простой язык разметки для этой задачи. Вот пример:

user> What time is my class?
* check
service> What is your e-mail address?
* prompt/email
user> [[email protected]](email/email)
* provide/email
service> You do not have any upcoming classes scheduled.
* upcoming/none

пользователь начинает беседу, а служба отвечает. Намерение, отмеченное звездочкой, следует за каждым сообщением, например. * check. А сущности, помеченные скобками, за которыми следует необязательный тип и имя в скобках, определяются внутри сообщений, например. [[email protected]](email/email).

Для этого проекта вы можете найти примеры обучающих бесед в проекте github. Добавьте их в свой новый проект Init.ai, и Init.ai автоматически заполнит для вас список намерений и сущностей. Затем просто нажмите Обучить модель в главном меню, и Init.ai создаст модель для работы приложения. Это займет несколько минут, а пока мы можем продолжить!

Наконец, в разделе Настройки подключите плагин обмена сообщениями. В Init.ai есть встроенный тестовый мессенджер, который вы можете найти в правом нижнем углу консоли Init.ai. Для этого проекта мы будем использовать Facebook Messenger, который поддерживает несколько дополнительных функций, которых нет в тестовом мессенджере. Сначала вам понадобится страница Facebook — вы можете создать новую здесь для тестирования — затем включите подключение к Facebook Messenger.

В остальной части этого проекта вы будете взаимодействовать со своим чат-ботом через Facebook Messenger. Как только ваша страница Facebook будет подключена, мы запустим сервер и создадим наше приложение.

"Приложение"

Теперь мы готовы погрузиться в код. Во-первых, мы запустим сервер.

*Запуск сервера*

Мы начнем с примерного проекта, который собрал Init.ai. Для начала клонируйте этот репозиторий:

git clone [email protected]:init-ai/sample-logic-server.git

Теперь установите базовые модули и пару дополнительных модулей, которые мы будем использовать:

npm install && npm install --save acuityscheduling moment

Мы почти готовы запустить сервер разработки. Во-первых, захватите свой идентификатор пользователя Acuity и ключ API из бизнес-настроек — интеграции. Мы передадим их в среду нашего сервера сейчас, потому что они понадобятся нам через минуту.

Затем, чтобы запустить сервер, выполните:

ACUITY_USER_ID=123  ACUITY_API_KEY=abc123  npm run dev

Наш сервер разработки запустит туннель «веб-перехватчика» для получения сообщений от Init.ai:

Просто скопируйте и вставьте этот веб-перехватчик в «Настройки» в разделе «Веб-перехватчики» в вашем проекте Init.ai. Примечание. Каждый раз, когда вы перезапускаете сервер разработки, вам нужно будет обновлять этот URL-адрес веб-перехватчика в настройках вашего проекта!

*Код приложения*

Этот пример приложения содержит немного стандартного кода, который стоит проверить, но все, что нам нужно отредактировать, находится в одном файле: src/runLogic.js. Код в этом модуле будет выполняться каждый раз, когда мы получаем событие (например, сообщение) от Init.ai через туннель веб-перехватчика, запущенный сервером.

Прежде всего, включите модули и немного конфигурации, с которыми мы будем работать:

/**
 * Run Logic for Booking Appointments with Init.ai
 */
const InitClient = require('initai-node');
const AcuityScheduling = require('acuityscheduling');
const moment = require('moment');
const dateFormat = 'MMM D, h:mma';

Сам этот модуль экспортирует функцию, которая запускается для каждого сообщения, которое мы получаем через Init.ai, и возвращает обещание:

module.exports = function runLogic(eventData) {
  return new Promise((resolve) => {
    // Application code goes here...
  });
};

Многие намерения нашего сервиса являются асинхронными и будут собирать данные из API Acuity — SDK Init.ai создан, чтобы помочь с этим типом асинхронной задачи. В теле промиса создайте экземпляры Acuity и Init.ai SDK:

// Create Init.ai client
    const client = InitClient.create(eventData, {succeed: resolve});
    // Create an Acuity client
    const acuity = new AcuityScheduling.basic({
      "userId": process.env.ACUITY_USER_ID,
      "apiKey": process.env.ACUITY_API_KEY
    });

Каждое изменение, которое мы вносим в этот файл, вызывает перезагрузку сервера разработки. Наши учетные данные Acuity уже находятся в среде сервера с момента его запуска, так что все готово! Теперь мы можем написать логику для двух диалоговых задач:

  1. Бронирование новых классов
  2. Проверка забронированных занятий

*Разговорная логика*

Разговоры в Init.ai контролируются потоком. Задачи для определенного диалога, такие как «записаться на занятие» или «проверить бронирование», называются потоками. Каждый поток может иметь несколько шагов, например getEmailAddress. И каждый шаг обычно соответствует паре намерений вопрос-ответ, обработке сущностей из сообщения для сохранения данных, запроса новых данных и т. д.

Все это настроено в методе client.runFlow, основной точке входа для нашей логики диалога Init.ai. Добавьте это в конец src/runLogic.js, а затем мы заполним шаги над ним:

// Set up the logic for our flow.
    //
    // We have two separate conversation streams: the default stream for
    // booking a new class, and a separate stream to get current bookings.
    // Conversations default to the bookClass stream, unless we receive a
    // 'check' intent.  Then we'll kick off the getBookings stream.
    client.runFlow({
      classifications: {
        'check': 'getBookings'
      },
      streams: {
        main: 'bookClass',
        bookClass: [getAppointmentType, getDatetime, getName, getEmail, bookAppointment],
        getBookings: [getEmail, getAppointments]
      }
    });

У нас есть два типа бесед, или потоки: bookClass и getBookings. Большинство людей бронируют занятия до того, как проверят свои заказы, поэтому мы определяем это как поток по умолчанию, используя атрибут main. Атрибут classification позволяет нам сопоставлять другие намерения с конкретными потоками. В нашем случае мы сопоставим намерение check с потоком getBookings.

Шаг 1: getAppointmentType

Теперь мы определим наш первый шаг: getAppointmentType. Добавьте этот шаг над вызовом runFlow:

//
    // Steps:
    //
    const getAppointmentType = client.createStep({
      /**
       * Get the appointment type from the client response.
       */
      extractInfo() { },
      /**
       * Satisfy this step once we have an appointment type:
       */
      satisfied() { },
      /**
       * Prompt for an appointment type if we don't have one stored.
       */
      prompt() { }
    });

Каждый шаг состоит из нескольких разных частей. В нашем приложении мы будем использовать extractInfo, satisfied и prompt. Метод extractInfo берет любые сущности и другие данные, извлеченные из сообщения, и решает, что с ними делать. Независимо от того, где мы находимся в разговоре, extractInfo запускается для каждого шага при получении любого сообщения. Чаттеры могут предоставлять информацию в неожиданном порядке или предоставлять информацию для нескольких шагов одновременно, но каждый шаг должен касаться только объектов этого шага.

«Здравствуйте. Меня зовут Иниго Монтойя. Ты убил моего отца. Приготовьтесь умереть».

Выполнение продолжается, и runFlow решает, в каком потоке мы находимся. Шаги для текущего потока выполняются по порядку, и вызывается satisfied. Если на шаге есть все необходимое и его можно считать завершенным, satisfied должно вернуть true. Для первого неудовлетворительного шага вызывается метод prompt и выполнение завершается.

На первом этапе мы извлекаем тип встречи из ответа (надеюсь!)

/**
       * Get the appointment type from the client response.
       */
      extractInfo() {
        // Store the appointment type ID in the conversation state
        const data = client.getPostbackData();
        if (data && data.appointmentTypeID) {
          client.updateConversationState({
            appointmentTypeID: data.appointmentTypeID
          });
        }
      },

client.getPostbackData() возвращает структурированные данные из предопределенных ответов в подсказке — мы рассмотрим это через минуту. Если мы получили идентификатор типа встречи, сохраните его в состоянии разговора, подобно сеансу в веб-приложении.

Этот шаг считается выполненным, когда мы сохраняем appointmentTypeID.

/**
       * Satisfy this step once we have an appointment type:
       */
      satisfied() { return Boolean(client.getConversationState().appointmentTypeID) },

Если он не удовлетворен, будет вызван prompt. Мы получим список типов встреч из API Acuity, отфильтруем его для общедоступных занятий и отправим ответ с этими параметрами в чат.

/**
       * Prompt for an appointment type if we don't have one stored.
       */
      prompt() {
        // Fetch appointment types from Acuity
        acuity.request('/appointment-types', function (err, res, appointmentTypes) {
          // Build some buttons for folks to choose a class
          const replies = appointmentTypes
            // Filter types for public classes
            .filter(appointmentType => appointmentType.type === 'class' && !appointmentType.private)
            // Create a button for each type
            .map(appointmentType => client.makeReplyButton(
              appointmentType.name,
              null,
              'bookClass',
              {appointmentTypeID: appointmentType.id}
            ));
          // Set the response intent to prompt to choose a type
          client.addResponseWithReplies('prompt/type', null, replies);
          // End the asynchronous prompt
          client.done();
        });
      }

Приятной особенностью Facebook Messenger является возможность создания удобных для пользователя кнопок ответа.

client.makeReplyButton( /* button text */, /* image */, /* stream */, /* structured data */ )

Наша подсказка типа встречи создает кнопку для каждого класса, которая отображает имя класса и сохраняет appointmentTypeID. Когда пользователь нажимает кнопку, мы получаем ответ с этими данными от метода getPostbackData, вызванного в extractInfo.

Ответ службы определяется с помощью client.addResponseWithReplies, отправляя prompt/type намерение и кнопки класса:

client.addResponseWithReplies('prompt/type', null, replies);

В последнюю очередь звонится client.done();. Мы сделали асинхронный запрос к API Acuity. client.done() разрешает обещание, и служба отправляет ответ обратно пользователю.

Вот полный шаг getAppointmentType:

const getAppointmentType = client.createStep({
      /**
       * Satisfy this step once we have an appointment type:
       */
      satisfied() { return Boolean(client.getConversationState().appointmentTypeID) },
      /**
       * Get the appointment type from the client response.
       */
      extractInfo() {
        // Store the appointment type ID in the conversation state
        const data = client.getPostbackData();
        if (data && data.appointmentTypeID) {
          client.updateConversationState({
            appointmentTypeID: data.appointmentTypeID
          });
        }
      },
      /**
       * Prompt for an appointment type if we don't have one stored.
       */
      prompt() {
        // Fetch appointment types from Acuity
        acuity.request('/appointment-types', function (err, res, appointmentTypes) {
          // Build some buttons for folks to choose a class
          const replies = appointmentTypes
            // Filter types for public classes
            .filter(appointmentType => appointmentType.type === 'class' && !appointmentType.private)
            // Create a button for each type
            .map(appointmentType => client.makeReplyButton(
              appointmentType.name,
              null,
              'bookClass',
              {appointmentTypeID: appointmentType.id}
            ));
          // Set the response intent to prompt to choose a type
          client.addResponseWithReplies('prompt/type', null, replies);
          // End the asynchronous prompt
          client.done();
        });
      }
    });

Шаг 2: getDatetime

Наш следующий шаг — выбрать конкретную сессию класса. Как и в случае с типами встреч, мы возьмем доступные сеансы занятий из Acuity API и предоставим список удобных кнопок на выбор. Мы извлечем сеанс datetime из данных обратной передачи и выполним шаг после сохранения даты и времени:

const getDatetime = client.createStep({
      satisfied() { return Boolean(client.getConversationState().datetime) },
      extractInfo() {
        // Store the datetime in the conversation state
        const data = client.getPostbackData();
        if (data && data.datetime) {
          client.updateConversationState({
            datetime: data.datetime
          });
        }
      },
      prompt() {
        // Fetch available class sessions from Acuity using the appointment type:
        const state = client.getConversationState();
        const options = {
          qs: {
            month: moment().format('YYYY-MM'),
            appointmentTypeID: state.appointmentTypeID
          }
        };
        acuity.request('/availability/classes', options, function (err, res, sessions) {
          // Build buttons for choosing a class session:
          const replies = sessions.map(session => client.makeReplyButton(
            moment(session.time).format(dateFormat),
            null,
            'bookClass',
            {datetime: session.time}
          ));
          // Ship the response:
          client.addResponseWithReplies('prompt/datetime', null, replies);
          client.done();
        });
      }
    });

Для получения информации о сеансе из Acuity мы будем использовать get availability/classesendpoint для текущего месяца и тип встречи, выбранный на первом этапе.

const options = {
          qs: {
            month: moment().format('YYYY-MM'),
            appointmentTypeID: state.appointmentTypeID
          }
        };
        acuity.request('/availability/classes', options, ...

Шаг 3: getEmail

В отличие от двух предыдущих шагов, которые извлекают информацию из выбора пользователем кнопки, getEmail получает адрес электронной почты entity непосредственно из сообщения клиента, используя getFirstEntityWithRole. Этот шаг также является общим — он используется как в потоке bookClass, так и в потоке getBookings.

const getEmail = client.createStep({
      satisfied() { return Boolean(client.getConversationState().email) },
      extractInfo() {
        // Get an e-mail provided by the user:
        const email = client.getFirstEntityWithRole(client.getMessagePart(), 'email/email');
        if (email) {
          client.updateConversationState({ email: email.value });
        }
      },
      prompt() {
        client.addResponse('prompt/email');
        // The getEmail step is used in multiple streams.  If we're not in the
        // default stream, set the expected next step.
        if (client.getStreamName() !== 'bookClass') {
          client.expect(client.getStreamName(), ['provide/email']);
        }
        client.done();
      }
    });

Подсказка для этого шага устанавливает ожидание, если мы не находимся в потоке по умолчанию:

client.expect(client.getStreamName(), ['provide/email']);

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

Шаг 4: getName

Теперь мы готовы собрать имя пользователя. Еще одно преимущество использования подключения к Facebook Messenger заключается в том, что он знает о профиле пользователя Facebook, включая его имя. Мы можем получить это с помощью client.getMessagePart().sender и автоматически сохранить в состоянии разговора.

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

const getName = client.createStep({
      satisfied() { return Boolean(
        client.getConversationState().firstName &&
        client.getConversationState().lastName)
      },
      extractInfo() {
        // Check for sender name set from Facebook,
        // or use the name provided by the user:
        const sender =  client.getMessagePart().sender;
        const firstName = client.getFirstEntityWithRole(client.getMessagePart(), 'firstName') || sender.first_name;
        const lastName = client.getFirstEntityWithRole(client.getMessagePart(), 'lastName') || sender.last_name;
        if (firstName) {
          client.updateConversationState({ firstName: firstName });
        }
        if (lastName) {
          client.updateConversationState({ lastName: lastName });
        }
      },
      prompt() {
        client.addResponse('prompt/name')
        client.done()
      }
    });

Шаг 5: bookAppointment

Наконец, у нас есть то, что нам нужно, чтобы записаться на прием:

  • имя клиента и адрес электронной почты
  • класс для бронирования
  • и дату и время.

Этот шаг не будет содержать шаг extractInfo(), так как мы больше ничего не ищем от пользователя, а метод satisfied() возвращает false, так как это последний шаг. Чтобы забронировать занятие, метод prompt() вызовет post appointments API Acuity с информацией о клиенте, которую мы собирали в состоянии разговора:

prompt() {
        // Get the whole conversation state:
        const state = client.getConversationState();
        // Book the class appointment using the gathered info
        const options = {
          method: 'POST',
          body: {
            appointmentTypeID: state.appointmentTypeID,
            datetime:          state.datetime,
            firstName:         state.firstName,
            lastName:          state.lastName,
            email:             state.email
          }
        };
        acuity.request('/appointments', options, function (err, res, appointment) { ... });

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

// Clear out conversation state.  This will reset our satisfied
          // conditions  and the user can schedule again.
          client.updateConversationState({
            appointmentTypeID: null,
            datetime: null
          });

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

// Send the confirmation message, with entities for the booking
          client.addResponse('confirmation', {
            type: appointment.type,
            datetime: moment(appointment.datetime).format(dateFormat)
          });

Вот полный список для шага bookAppointment:

const bookAppointment = client.createStep({
      // This is the final step:
      satisfied() { return false; },
      prompt() {
        // Get the whole conversation state:
        const state = client.getConversationState();
        // Book the class appointment using the gathered info
        const options = {
          method: 'POST',
          body: {
            appointmentTypeID: state.appointmentTypeID,
            datetime:          state.datetime,
            firstName:         state.firstName,
            lastName:          state.lastName,
            email:             state.email
          }
        };
        acuity.request('/appointments', options, function (err, res, appointment) {
          // Clear out conversation state.  This will reset our satisfied
          // conditions  and the user can schedule again.
          client.updateConversationState({
            type: null,
            appointmentTypeID: null,
            datetime: null
          });
          // Send the confirmation message, with entities for the booking
          client.addResponse('confirmation', {
            type: appointment.type,
            datetime: moment(appointment.datetime).format(dateFormat)
          });
          client.done();
        });
      }
    });

Выполнив эти шаги, вы сможете взаимодействовать с ботом бронирования через Facebook Messenger и назначать встречу: https://www.messenger.com/

Проверка встреч

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

Мы будем использовать get appointments API Acuity вместе с двумя параметрами: email и minDate, установленными на данный момент:

// Get upcoming appointments matching an e-mail address:
        const state = client.getConversationState();
        const options = {
          qs: {
            email: state.email,
            minDate: moment().toISOString()
          }
        };
        acuity.request('/appointments', options, function (err, res, appointments) { ... }

В ответе мы проверим, есть ли предстоящие встречи, и решим, какой ответ intent отправить клиенту:

// Decide which response intent to send: the upcoming schedule, or none:
          if (appointments.length) {
            // format upcoming schedule and response...
          } else {
            // If no appointments, send none intent:
            client.addResponse('upcoming/none');
          }

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

// Sort upcoming classes chronologically and format response
            const classes = "\n" + appointments
              .sort((a, b) => b.datetime < a.datetime )
              .map(appointment =>
                moment(appointment.datetime).format(dateFormat)+': '+appointment.type
              ).join(", \n")
            // Send upcoming appointments intent, with entities:
            client.addResponse('upcoming/appointments', {
              'number/count': appointments.length,
              'classes': classes
            });

Непосредственно перед вызовом client.done() мы очистим ожидаемый поток с помощью client.expect(null). Это сбрасывает подсказку, установленную в getEmail, позволяя клиенту войти в другой поток, например, забронировать другой класс после проверки своего расписания. Вот полный список для нашего последнего шага:

const getAppointments = client.createStep({
      satisfied() { return false; },
      prompt() {
        // Get upcoming appointments matching an e-mail address:
        const state = client.getConversationState();
        const options = {
          qs: {
            email: state.email,
            minDate: moment().toISOString()
          }
        };
        acuity.request('/appointments', options, function (err, res, appointments) {
          // Decide which response intent to send: the upcoming schedule, or none:
          if (appointments.length) {
            // Sort upcoming classes chronologically and format response
            const classes = "\n" + appointments
              .sort((a, b) => b.datetime < a.datetime )
              .map(appointment =>
                moment(appointment.datetime).format(dateFormat)+': '+appointment.type
              ).join(", \n")
            // Send upcoming appointments intent, with entities:
            client.addResponse('upcoming/appointments', {
              'number/count': appointments.length,
              'classes': classes
            });
          } else {
            // If no appointments, send none intent:
            client.addResponse('upcoming/none');
          }
          // Clear the expected stream after getting appointments:
          client.expect(null);
          client.done();
        });
      }
    });

Теперь, когда это реализовано, мы можем проверить наше расписание:

"Вывод"

Ну эта статья не сама написалась! Но разговорный ИИ прошел долгий путь и сегодня как никогда удобен для разработчиков. Создание бота, который хорошо справляется с двумя задачами, которые я ему дал, — это неплохо для дня!

Вы можете найти полный исходный код этого проекта на github. Отсюда можно сделать два основных улучшения:

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

Подключение диалогового интерфейса к существующим API, таким как Acuity Scheduling, уже является практичным способом добавления полезных диалоговых функций в другие приложения, такие как Facebook Messenger. Дальше будет только лучше!

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

Большое спасибо Карлу Сазерленду за то, что он написал это руководство и поделился с нами.