Четыре месяца назад у меня появилась идея написать Telegram-бота, который будет запускаться не на внешнем сервере, как большинство ботов, а на мобильном телефоне.
Идея родилась не на пустом месте: я часто пропускал входящие звонки и СМС, когда телефон был в куртке или в кармане, поэтому мне нужен был дополнительный способ уведомлений. А так как я активно использую Telegram на компьютере, то подумал, что было бы не плохо, если бы входящие СМС и пропущенные звонки приходили в Telegram. Немного покопавшись, я решил написать бота.
Разработка прототипа
Я стал изучать тему создания Telegram ботов по официальной документации и по примерам. В основном все примеры были написаны на Python. Поэтому не долго думая, стал искать способы запуска Python сервера на Android. Но оценив время на изучение Python и не найдя ничего подходящего для запуска сервера, занялся поиском альтернатив и наткнулся на несколько библиотек на Java для написания Telegram ботов. В итоге остановился на проекте от Pengrad: java-telegram-bot-api.
Данная библиотека позволяла, на тот момент, инициализировать бота и получать-отправлять сообщения, что мне было и нужно. Добавив библиотеку в свой проект, я реализовал простой сервис, который запускал в фоновом потоке цикл по получению сообщений из Telegram и их обработке. Предварительно необходимо было зарегистрировать нового бота через родительский бот @Botfather и получить его токен. Подробнее о создании бота по ссылке.
Для того, чтобы сервис не убивался системой, когда устройство находится с выключенным экраном, при запуске сервиса, устанавливался WakeLock.
Приведу в пример функцию, позволяющую получать последние сообщения и отправлять их на обработку:
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 разрешает использование самоподписанных сертификатов, поэтому я стал разбираться, как создать свой сертификат.
После нескольких часов, проведенных в попытках и поисках, получился скрипт, позволяющий сгенерировать нужный сертификат.
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); } }); }); }
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/
Добавить комментарий