[Из песочницы] Запускаем 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 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 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
→ Сайт проекта

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

  • 2 января 2017 в 01:18

    0

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

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

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

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

© Habrahabr.ru