Запускаем 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
Сайт проекта
ссылка на оригинал статьи https://habrahabr.ru/post/318882/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *