Четыре месяца назад у меня появилась идея написать 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, создал
Пуш-нотификации отправлялись с помощью 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
→ Сайт проекта
Автор: alexjcomp