Monday, January 2, 2017

Запускаем Telegram-бота на Android устройстве / Хабрахабр

Android developer
8,8
рейтинг
вчера в 22:17

Разработка → Запускаем Telegram-бота на Android устройстве из песочницы

Четыре месяца назад у меня появилась идея написать Telegram-бота, который будет запускаться не на внешнем сервере, как большинство ботов, а на мобильном телефоне.

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

Разработка прототипа


Я стал изучать тему создания Telegram ботов по официальной документации и по примерам. В основном все примеры были написаны на Python. Поэтому не долго думая, стал искать способы запуска Python сервера на Android. Но оценив время на изучение Python и не найдя ничего подходящего для запуска сервера, занялся поиском альтернатив и наткнулся на несколько библиотек на Java для написания Telegram ботов. В итоге остановился на проекте от Pengrad: java-telegram-bot-api.

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

Для того, чтобы сервис не убивался системой, когда устройство находится с выключенным экраном, при запуске сервиса, устанавливался WakeLock.

Приведу в пример функцию, позволяющую получать последние сообщения и отправлять их на обработку:

private void getUpdates(final TelegramBot bot)
private void getUpdates(final TelegramBot bot) {          try {              GetUpdatesResponse response = bot.execute(                      new GetUpdates()                              .limit(LIMIT)                              .offset(updateId.get())                              .timeout(LONG_POLLING_TIMEOUT));                if (response != null && response.updates() != null && response.updates().size() > 0) {                  for (Update update : response.updates()) {                      obtainUpdate(bot, update);                      updateId.set(update.updateId() + 1);                  }              }          } catch (Exception e) {              ErrorUtils.log(TAG, e);          }      }


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

Добавив несколько команд для бота, такие как: отправка, чтение СМС, просмотр пропущенных звонков, информация о батарее, определение местоположения и др., я опубликовал приложение в Google Play, создал темы на нескольких форумах, стал ждать комментарии и отзывы.

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

Это немного помогло, но появилась другая проблема, AlarmManager некорректно работал на некоторых китайских устройствах. И поэтому бот иногда не просыпался после нескольких часов, проведенных в состоянии сна. Изучая официальную документацию, я читал о том, что Long Polling это не единственная возможность получения сообщений, сообщения еще можно было получать используя Webhook.

Получение сообщений через Webhook


Я зарегистрировался на Digital Ocean, создал VPS на Ubuntu, затем реализовал простейший http сервер на Java, использующий Spark Framework. На сервер можно делать запросы 2 типов: push (отправка пуш-уведомления через webhook) и ping.

Пуш-нотификации отправлялись с помощью Google Firebase.

Пример класса, помогающего отправить пуш-уведомления
public class PushHelper {      private static final String URL = "https://fcm.googleapis.com/fcm/send";      private static java.util.logging.Logger log = java.util.logging.Logger.getLogger(PushHelper.class.getName());      private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");      private static final String AUTHORIZATION = "...";        public static String push(PushRequest pushRequest) throws IOException {          ObjectMapper objectMapper = new ObjectMapper();          return post(URL, objectMapper.writeValueAsString(pushRequest));      }        private static String post(String url, String json) throws IOException {          RequestBody body = RequestBody.create(JSON, json);          Request request = new Request.Builder()                  .url(url)                  .header("Authorization", AUTHORIZATION)                  .post(body)                  .build();          OkHttpClient client = getSslClient();          if (client != null) {              Response response = client.newCall(request).execute();              return response.body().string();          } else {              throw new IOException("Unable to init okhttp client");          }      }  ...  }


Модель запроса, необходимого для отправки пуш-нотификации
public class PushRequest {      private PushData data; //Данные, отправляемые на устройство      private String to;  //Пуш-токен устройства      private String priority = "high"; //Приоритет сообщения      ...  }

Для того, чтобы сообщение приходило даже когда устройство находится в состоянии сна, нужно указать priority = «high»

Генерация SSL сертификата


Протестировав отправку пуш-уведомлений, я стал разбираться с тем, как настроить и запустить сервер с HTTPS, так как это одно из требований при получении сообщений из Telegram через webhook.

Бесплатный сертификат можно сгенерировать с помощью сервиса letsencrypt.org, но одним из ограничений является то, что указываемый хост при генерации сертификата не может быть ip адресом. Регистрировать доменное имя я пока не хотел, тем более официальная документация Telegram Bot API разрешает использование самоподписанных сертификатов, поэтому я стал разбираться, как создать свой сертификат.

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

create_cert.sh
openssl req -newkey rsa:2048 -sha256 -nodes -keyout private.key -x509 -days 365 -out public_cert.pem -subj "/C=RU/ST=State/L=Location/O=Organization/CN=ServerHost"    openssl pkcs12 -export -in public_cert.pem -inkey private.key -certfile public_cert.pem -out keystore.p12  keytool -importkeystore -srckeystore keystore.p12 -srcstoretype pkcs12 -sigalg SHA1withRSA -destkeystore keystore.jks -deststoretype JKS  rm keystore.p12  rm private.key


После запуска скрипта, на выходе получаем два файла: keystore.jks — используется на сервере, public_cert.pem — используется при установке webhook в Android приложении.

Для того, чтобы запустить HTTPS на Spark Framework достаточно добавить 2 строки, одну указывающую порт (разрешенные порты для webhook: 443, 80, 88, 8443), другую, указывающую сгенерированный сертификат и пароль к нему:

port(8443);  secure("keystore.jks", "password", null, null);

Чтобы установить webhook для бота, необходимо добавить в андроид-приложение следующие строки:

SetWebhook setWebHook = new SetWebhook().url(WEBHOOK_URL + "/" + pushToken + "/" + secret).certificate(getCert(context));  BaseResponse res = bot.execute(setWebHook);

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

Функция чтения публичного сертификата из RAW ресурса:

private static byte[] getCert(Context context) throws IOException {          return IOUtils.toByteArray(context.getResources().openRawResource(R.raw.public_cert));  }

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

Автоматическое создание бота


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

В этом мне помогла библиотека tdlib от создателей Telegram. К сожалению, я нашел очень мало примеров использования этой библиотеки, но разобравшись в API, оказалось, что не так все сложно. В итоге удалось реализовать авторизацию в Telegram по номеру телефона, добавление @Botfather в список контактов и отправку и получение сообщений заданному контакту, а в конкретном случае, боту @Botfather.

Пример функций по отправке-получении сообщений
private Observable<TdApi.Message> sendMessage(long chatId, String text) {          return Observable.create(subscriber -> {              telegramClient.sendMessage(chatId, text, object -> {                  if (object instanceof TdApi.Error) {                      subscriber.onError(new Throwable(((TdApi.Error) object).message));                  } else {                      TdApi.Message message = (TdApi.Message) object;                      subscriber.onNext(message);                  }              });          }).delay(5, TimeUnit.SECONDS).flatMap(msg -> getLastIncomingMessage(((TdApi.Message) msg).chatId, ((TdApi.Message) msg).senderUserId, ((TdApi.Message) msg).id));      }        private Observable<TdApi.Message> getLastIncomingMessage(long chatId, int userId, int outgoingMessageId) {          return Observable.create(subscriber -> {              telegramClient.getLastIncomingMessage(chatId, outgoingMessageId, userId, object -> {                  if (object instanceof TdApi.Error) {                      subscriber.onError(new Throwable(((TdApi.Error) object).message));                  } else {                      TdApi.Message message = (TdApi.Message) object;                      subscriber.onNext(message);                  }              });          });      }


TelegramClient.java - класс-обертка над TdApi
  public class TelegramClient {      private final Client client;        public TelegramClient(Context context, Client.ResultHandler updatesHandler) {          TG.setDir(context.getCacheDir().getAbsolutePath());          TG.setFilesDir(context.getFilesDir().getAbsolutePath());          client = TG.getClientInstance();          TG.setUpdatesHandler(updatesHandler);      }        public void clearAuth(Client.ResultHandler resultHandler) {          TdApi.ResetAuth request = new TdApi.ResetAuth(true);          client.send(request, resultHandler);      }        public void getAuthState(Client.ResultHandler resultHandler) {          TdApi.GetAuthState req = new TdApi.GetAuthState();          client.send(req, resultHandler);      }        public void sendPhone(String phone, Client.ResultHandler resultHandler) {          TdApi.SetAuthPhoneNumber smsSender = new TdApi.SetAuthPhoneNumber(phone, false, true);          client.send(smsSender, resultHandler);        }        public void checkCode(String code, String firstName, String lastName, Client.ResultHandler resultHandler) {          TdApi.CheckAuthCode request = new TdApi.CheckAuthCode(code, firstName, lastName);          client.send(request, resultHandler);      }          public void sendMessage(long chatId, String text, Client.ResultHandler resultHandler) {          TdApi.InputMessageContent msg = new TdApi.InputMessageText(text, false, false, null, null);          TdApi.SendMessage request = new TdApi.SendMessage(chatId, 0, false, false, null, msg);          client.send(request, resultHandler);      }        public void getLastIncomingMessage(long chatId, int fromMessageId, int userId, Client.ResultHandler resultHandler) {          getChat(chatId, chatObj -> {              if (chatObj instanceof TdApi.Chat) {                  TdApi.GetChatHistory getChatHistory = new TdApi.GetChatHistory(chatId, fromMessageId, -1, 2);                  client.send(getChatHistory, messagesObj -> {                      if (messagesObj instanceof TdApi.Messages) {                          TdApi.Messages messages = (TdApi.Messages) messagesObj;                          if (messages.totalCount > 0) {                              for (TdApi.Message message : messages.messages) {                                    if (message.id != fromMessageId && message.senderUserId != userId) {                                      resultHandler.onResult(message);                                      return;                                  }                              }                          }                          resultHandler.onResult(new TdApi.Error(0, "Unable to get incoming message"));                      } else resultHandler.onResult(messagesObj);                  });              } else resultHandler.onResult(chatObj);          });          }        public void getChat(long chatId, Client.ResultHandler resultHandler) {          TdApi.GetChat getChat = new TdApi.GetChat(chatId);          client.send(getChat, resultHandler);      }          public void searchContact(String username, Client.ResultHandler resultHandler) {          TdApi.SearchPublicChat searchContacts = new TdApi.SearchPublicChat(username);          client.send(searchContacts, resultHandler);      }        public void getMe(Client.ResultHandler resultHandler) {          client.send(new TdApi.GetMe(), resultHandler);      }        public void changeUsername(String username, Client.ResultHandler resultHandler) {          client.send(new TdApi.ChangeUsername(username), resultHandler);      }        public void startChatWithBot(int botUserId, long chatId, Client.ResultHandler resultHandler) {            TdApi.CloseChat closeChat = new TdApi.CloseChat(chatId);          client.send(closeChat, resClose -> {              TdApi.OpenChat openChat = new TdApi.OpenChat(chatId);              client.send(openChat, resOpen -> {                  if (resOpen instanceof TdApi.Error) {                      resultHandler.onResult(resOpen);                      return;                  }                    TdApi.SendBotStartMessage request = new TdApi.SendBotStartMessage(botUserId, chatId, "/start");                  client.send(request, resultHandler);              });          });      }        public void logout(Client.ResultHandler resultHandler) {          client.send(new TdApi.ResetAuth(false), resultHandler);      }  }  


Добавление новых возможностей


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

По просьбам пользователей, я также добавил возможность вызова команд Tasker и отправки сообщений из Tasker в Telegram.

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

Библиотека
Пример использования

Заключение


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

Большое спасибо за внимание. Буду рад услышать от Вас полезные замечания и предложения.

Ссылки:
Приложение в Google Play
Канал в Telegram
Сайт проекта
Alexander Shtanko @alexjcomp
карма
2,0
рейтинг 8,8
Android developer

Самое читаемое Разработка

Комментарии (1)

  • +2
    Но оценив время на изучение Python и не найдя ничего подходящего для запуска сервера,

    Есть python for android, который позволяет написать полноценное приложение.
    Так же есть возможность запустить linux окружение, а так делать практически все, что хочется. Писал как то об этом.

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

    Есть готовое решение: pushbullet. Ставите приложение на смартфон, ставите расширение для браузера. И теперь можете «пушить» сообщения (ссылки, фотографии и пр) в любом направлении. Отдельно, включается уведомлялся о звонках и сообщениях. Очень удобно: при входящих звонках или каких либо уведомлениях, появляется всплывающее окно на компьютере.

    P.S. Я не придираюсь, так, для расширения кругозора )

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.



Original Page: https://habrahabr.ru/post/318882/



Sent from my iPad

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.