Выкуси, Telegram Premium — бот-конвертер голосовых сообщений для обхода ограничений (Java, Spring, вебхуки, ffmpeg)
В предыдущих сериях
Это третья статья в моей серии «для самых маленьких» — первая была посвящена «классическому» Telegram-боту, наследуемому от TelegramLongPollingBot
, вторая — боту на вебхуках на Spring с БД Redis и клавиатурами.
Для кого написано
Если вы ни разу не писали Telegram-ботов на Java с использованием вебхуков и только начинаете разбираться — эта статья для вас. В ней подробно и с пояснениями описано создание реального бота, автоматизирующего одну очень простую функцию. Можно использовать статью как мануал для создания скелета своего бота, а потом подключить его к своей бизнес-логике.
Я пытаюсь писать как для себя, а не сразу для умных — надеюсь, кому-нибудь это поможет быстрее въехать в тему.
Предыстория
Давать доступ к возможностям продукта только покупателям подписки — нормально, это бизнес. Выводить раздражающую значительную часть пользователей фичу, а потом разрешать отказаться от неё только за деньги — поедание экскрементов.
Большинство преимуществ Telegram Premium не вызывают никаких вопросов, но запрет на отправку себе голосовых сообщений за деньги — это низко, Telegram.
К счастью, наш любимый мессенджер настолько хорош, что обойти эту несправедливость можно с помощью очень простого Voice4PremiumBot.
Что в статье есть, чего нет
В статье есть про:
создание бекенда Telegram-бота на вебхуках на Java 11 с использованием Spring;
отправку пользователю текстовых сообщений, изображений и аудио;
конвертацию файлов .ogg в .mp3;
удаление временных файлов по расписанию;
локальный запуск бота;
использование утилиты ngrok для локального дебага бота на вебхуках;
создание тестового метода для проверки работы приложения без использования Telegram для локализации проблемы при дебаге.
В статье нет про:
общение с BotFather (создание бота и получение его токена подробно и понятно описано во многих источниках, вот первый попавшийся мануал);
деплой — в предыдущей статье есть подробный порядок развёртывания на Heroku, повторяться не буду.
Исходный код лежит на GitHub. Если у вас вдруг есть вопросы, пишите в личку, с удовольствием проконсультирую.
Бизнес-функции бота
Бот позволяет:
выводить картинку-справку в ответ на команду /start;
конвертировать голосовые сообщения пользователя в файлы формата .mp3;
оповещать пользователя о неверном формате сообщения или возникшей ошибке.
Пользоваться просто — отправить боту голосовое сообщение, получить в ответ файл .mp3 с тем же аудио-содержимым, переслать пользователю Telegram Premium и наблюдать реакцию. Получатель не поймёт, что файл перенаправлен из бота — на файле отсутствует пометка «forwarded from …». Уровень и длительность дальнейшего троллинга — на ваш вкус.
Можно потыкать — Voice4PremiumBot. Выглядит так:
Способы, которые не взлетели
Конечно, хотелось запилить бота совсем на скорую руку, без конвертации файлов, но Telegram последовательно не позволил сделать это. Не удалось:
получить от Telegram
fileId
и отправить его обратно, но как audio или document, а не voice — отправляет всё равно как voice;скачать файл .ogg (используя тот же
fileId
) и отправить его обратно, но как audio или document, а не voice — отправляет всё равно как voice.
Делаем вывод, что Telegram воспринимает любой файл .ogg как голосовое сообщение -, но только отправленный через API, поскольку через интерфейс .ogg можно отправить как файл, в том числе пользователям Telegram Premium.
Ну что ж, конвертировать как конвертировать.
Порядок разработки
разобраться с зависимостями;
создать бота;
обработать сообщения пользователя;
разобраться с конвертированием файлов;
научиться взаимодействовать с API Telegram;
локально запустить.
Ниже подробно расписан каждый пункт.
Зависимости
Для управления зависимостями используем Apache Maven. Нужные зависимости — собственно Telegram Spring Boot, Lombok и библиотека ffmpeg-cli-wrapper для конвертации аудио-файлов.
Создаём вот такой
pom.xml
org.springframework.boot
spring-boot-starter-parent
2.2.0.RELEASE
4.0.0
ru.taksebe.telegram
premium-audio
1.0-SNAPSHOT
premium-audio
Накажи мажора с премиумом!
jar
11
1.7.30
${java.version}
${java.version}
UTF-8
UTF-8
org.springframework.boot
spring-boot-starter-web
org.telegram
telegrambots-spring-boot-starter
5.3.0
org.projectlombok
lombok
1.18.20
compile
net.bramp.ffmpeg
ffmpeg
0.7.0
org.springframework.boot
spring-boot-maven-plugin
build-info
${project.build.sourceEncoding}
${project.reporting.outputEncoding}
${maven.compiler.source}
${maven.compiler.target}
Создаём бота
Нам понадобится файл настроек application — я предпочитаю делать его в формате .yaml, но если вам удобнее .properties — не суть:
application.yaml
telegram:
api-url: "https://api.telegram.org/"
bot-name: "Имя бота - от BotFather"
bot-token: "Токен бота - от BotFather"
webhook-path: "Адрес вебхука - локально получаем от ngrok"
server:
port: "для локального дебага через ngrok я использую 5000"
files:
incoming: "префикс названия временных файлов голосовых сообщений - нужен, чтобы найти потом эти временные файлы и удалить их"
outgoing: "префикс названия временных файлов .mp3 - нужен, чтобы найти потом эти временные файлы и удалить их"
ffmpeg:
path: "путь до файла ffmpeg (если запускается под Linux) или ffmpeg.exe (если под Windows)"
schedule:
cron:
delete-temp-files: 0 */10 * ? * * //крон для удаления временных файлов
message:
start:
picture-file-id: "Telegram-идентификатор картинки, отправляемой пользователю в ответ на команду /start"
text: "текст сообщения в ответ на команду /start"
too-big-voice:
text: "текст сообщения в ответ на отправку слишком длинного голосового сообщения (лимит - 10 минут)"
illegal-message:
text: "текст сообщения в ответ на отправку любого типа сообщений, кроме /start и голосовых"
wtf:
text: "текст сообщения в случае возникновения внутренней ошибки работы приложения"
Чтобы достать настройки, нужные для работы бота, создадим конфигурационный файл:
TelegramConfig.java
import lombok.AccessLevel;
import lombok.Getter;
import lombok.experimental.FieldDefaults;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
@Getter
@FieldDefaults(level = AccessLevel.PRIVATE)
public class TelegramConfig {
@Value("${telegram.webhook-path}")
String webhookPath;
@Value("${telegram.bot-name}")
String botName;
@Value("${telegram.bot-token}")
String botToken;
@Value("${message.too-big-voice.text}")
String tooBigVoiceText;
@Value("${message.illegal-message.text}")
String illegalMessageText;
@Value("${message.wtf.text}")
String wtfText;
}
Создадим класс для самого бота. Он получает сообщения, отсекает на всякий случае пустые и перенаправляет их в класс-обработчик. Кроме того, в случае возникновения ошибок обработки класс перехватывает исключения и в зависимости от их типа отправляет пользователю нужную текстовку из настроек:
WriteReadBot.java
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import org.telegram.telegrambots.meta.api.methods.BotApiMethod;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.methods.updates.SetWebhook;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.starter.SpringWebhookBot;
import ru.taksebe.telegram.premium.exceptions.TooBigVoiceMessageException;
import java.io.IOException;
@Getter
@Setter
@FieldDefaults(level = AccessLevel.PRIVATE)
public class WriteReadBot extends SpringWebhookBot {
String botPath;
String botUsername;
String botToken;
String tooBigVoiceText;
String illegalMessageText;
String wtfText;
MessageHandler messageHandler;
public WriteReadBot(SetWebhook setWebhook, MessageHandler messageHandler) {
super(setWebhook);
this.messageHandler = messageHandler;
}
@Override
public BotApiMethod> onWebhookUpdateReceived(Update update) {
try {
return handleUpdate(update);
} catch (TooBigVoiceMessageException e) {
return new SendMessage(update.getMessage().getChatId().toString(), this.tooBigVoiceText);
} catch (IllegalArgumentException e) {
return new SendMessage(update.getMessage().getChatId().toString(), this.illegalMessageText);
} catch (Exception e) {
return new SendMessage(update.getMessage().getChatId().toString(), this.wtfText);
}
}
private BotApiMethod> handleUpdate(Update update) throws IOException {
if (update.hasCallbackQuery()) {
return null;
} else {
Message message = update.getMessage();
if (message != null) {
return messageHandler.answerMessage(message);
}
return null;
}
}
}
Нам понадобится бин бота, и мы создадим его в ещё одном конфигурационном файле, используя настройки бота и вебхука:
SpringConfig.java
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.telegram.telegrambots.meta.api.methods.updates.SetWebhook;
import ru.taksebe.telegram.premium.telegram.MessageHandler;
import ru.taksebe.telegram.premium.telegram.WriteReadBot;
@Configuration
@AllArgsConstructor
public class SpringConfig {
private final TelegramConfig telegramConfig;
@Bean
public SetWebhook setWebhookInstance() {
return SetWebhook.builder().url(telegramConfig.getWebhookPath()).build();
}
@Bean
public WriteReadBot springWebhookBot(SetWebhook setWebhook,
MessageHandler messageHandler) {
WriteReadBot bot = new WriteReadBot(setWebhook, messageHandler);
bot.setBotPath(telegramConfig.getWebhookPath());
bot.setBotUsername(telegramConfig.getBotName());
bot.setBotToken(telegramConfig.getBotToken());
bot.setTooBigVoiceText(telegramConfig.getTooBigVoiceText());
bot.setIllegalMessageText(telegramConfig.getIllegalMessageText());
bot.setWtfText(telegramConfig.getWtfText());
return bot;
}
}
Используя бин бота, создаём контроллер:
WebhookController.java
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.telegram.telegrambots.meta.api.methods.BotApiMethod;
import org.telegram.telegrambots.meta.api.objects.Update;
import ru.taksebe.telegram.premium.telegram.WriteReadBot;
@RestController
@AllArgsConstructor
public class WebhookController {
private final WriteReadBot writeReadBot;
@PostMapping("/premium")
public BotApiMethod> onUpdateReceived(@RequestBody Update update) {
return writeReadBot.onWebhookUpdateReceived(update);
}
}
И, наконец, нам нужно приложение, чтобы запустить всё это великолепие. Добавляем аннотацию EnableScheduling
— она позволяет поддерживать работу по расписанию и понадобится нам для удаления временных файлов, об этом ниже:
PremiumAudioTelegramBotApplication.java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class PremiumAudioTelegramBotApplication {
public static void main(String[] args) {
SpringApplication.run(PremiumAudioTelegramBotApplication.class, args);
}
}
Бот создан, но он не работает — никто не разбирает сообщения пользователя, не конвертирует аудио и ничего не отправляет в Telegram.
Разбираем сообщение пользователя
Пользователь может отправить боту всего два типа легальных сообщений — стандартную команду /start
и голосовое сообщение. В ответ на первую бот отправляет инструкцию в виде картинки с текстом, а голосовухи отправляются в конвертер.
Для подготовки к конвертации необходимо:
проверить длительность голосового сообщения — чтобы не создавать повышенной нагрузки, сообщения длиной больше 10 минут не обрабатываются;
скачать файл голосовухи — в сообщении приходит только его идентификатор, который мы отправляем в
TelegramApiClient
и получаем в ответ временный файл .ogg;создать временный файл .mp3 для отправки в конвертер — он «наполнит» его аудио из голосового сообщения.
После завершения конвертации файл .mp3 отправляется пользователю через API Telegram в виде массива байт, а хулиганства ради мы ещё и переопределяем метод получения названия файла, делая его максимально визуально похожим на интерфейс голосового сообщения в Telegram:
MessageHandler.java
import lombok.AccessLevel;
import lombok.experimental.FieldDefaults;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.meta.api.methods.BotApiMethod;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.Voice;
import ru.taksebe.telegram.premium.exceptions.TooBigVoiceMessageException;
import ru.taksebe.telegram.premium.utils.Converter;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
@Component
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
public class MessageHandler {
Converter converter;
TelegramApiClient telegramApiClient;
String tempFileNamePrefix;
public MessageHandler(Converter converter,
TelegramApiClient telegramApiClient,
@Value("${files.outgoing}") String tempFileNamePrefix) {
this.converter = converter;
this.telegramApiClient = telegramApiClient;
this.tempFileNamePrefix = tempFileNamePrefix;
}
public BotApiMethod> answerMessage(Message message) throws IOException {
if (message.hasVoice()) {
convertVoice(message);
} else if (message.getText() != null && message.getText().equals("/start")) {
telegramApiClient.uploadStartPhoto(message.getChatId().toString());
} else {
throw new IllegalArgumentException();
}
return null;
}
private void convertVoice(Message message) throws IOException {
Voice voice = message.getVoice();
if (voice.getDuration() > 600) {
throw new TooBigVoiceMessageException();
}
File source = telegramApiClient.getVoiceFile(voice.getFileId());
File target = File.createTempFile(this.tempFileNamePrefix, ".mp3");
try {
converter.convertOggToMp3(source.getAbsolutePath(), target.getAbsolutePath());
} catch (Exception e) {
throw new IOException();
}
telegramApiClient.uploadAudio(message.getChatId().toString(),
new ByteArrayResource(Files.readAllBytes(target.toPath())) {
@Override
public String getFilename() {
return "IlııIIIıııIııııııIIIIllıııııIıııııı.mp3";
}
}
);
}
}
Конвертируем аудио
Конвертацию будет осуществлять ffmpeg — необходимо скачать нужную версию с официального сайта и положить в resources, чтобы наш класс-конвертер мог его найти.
Кстати, создадим его — он будет конвертировать один временный файл в другой, используя библиотеку ffmpeg-cli-wrapper и путь до файла ffmpeg из настроек:
Converter.java
import net.bramp.ffmpeg.FFmpeg;
import net.bramp.ffmpeg.FFmpegExecutor;
import net.bramp.ffmpeg.builder.FFmpegBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
@Component
public class Converter {
private final FFmpeg ffmpeg;
public Converter(@Value("${ffmpeg.path}") String ffmpegPath) throws IOException {
this.ffmpeg = new FFmpeg(new File(ffmpegPath).getPath());
}
public void convertOggToMp3(String inputPath, String targetPath) throws IOException {
FFmpegBuilder builder = new FFmpegBuilder()
.setInput(inputPath)
.overrideOutputFiles(true)
.addOutput(targetPath)
.setAudioCodec("libmp3lame")
.setAudioBitRate(32768)
.done();
FFmpegExecutor executor = new FFmpegExecutor(this.ffmpeg);
executor.createJob(builder).run();
try {
executor.createTwoPassJob(builder).run();
} catch (IllegalArgumentException ignored) {//отлавливаем и игнорируем ошибку, возникающую из-за отсутствия видеоряда (конвертер предназначен для видео)
}
}
}
Общаемся с API Telegram
API Telegram нам нужно для работы с файлами:
отправлять пользователю стартовое сообщение в виде картинки с текстом (метод
uploadStartPhoto(String chatId)
). Идентификатор картинки и текст — из настроек;скачивать голосовое сообщение во временный файл .ogg по его идентификатору (метод
getVoiceFile(String fileId)
), присваивая нужный префикс в название для последующего удаления по расписанию;отправлять пользователю аудио в виде файла .mp3 (метод
uploadAudio(String chatId, ByteArrayResource value)
).
Идентификатор картинки проще всего получить уже после первого запуска бота, направив ему нужное изображение — да, команда /start у вас в итоге упадёт, но перед этим под дебагом можно изучить объект Message
и найти во вложенном списке photo
в любом из трёх объектов поле fileId
.
Получаем вот такого REST-клиента для общения с Telegram:
TelegramApiClient.java
import lombok.AccessLevel;
import lombok.experimental.FieldDefaults;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestTemplate;
import org.telegram.telegrambots.meta.api.objects.ApiResponse;
import ru.taksebe.telegram.premium.exceptions.TelegramFileNotFoundException;
import ru.taksebe.telegram.premium.exceptions.TelegramFileUploadException;
import java.io.File;
import java.io.FileOutputStream;
import java.text.MessageFormat;
import java.util.Objects;
@Service
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
public class TelegramApiClient {
String URL;
String botToken;
String startMessagePhotoFileId;
String startMessageText;
String tempFileNamePrefix;
RestTemplate restTemplate;
public TelegramApiClient(@Value("${telegram.api-url}") String URL,
@Value("${telegram.bot-token}") String botToken,
@Value("${message.start.picture-file-id}") String startMessagePhotoFileId,
@Value("${message.start.text}") String startMessageText,
@Value("${files.incoming}") String tempFileNamePrefix) {
this.URL = URL;
this.botToken = botToken;
this.tempFileNamePrefix = tempFileNamePrefix;
this.startMessagePhotoFileId = startMessagePhotoFileId;
this.startMessageText = startMessageText;
this.restTemplate = new RestTemplate();
}
public void uploadStartPhoto(String chatId) {
LinkedMultiValueMap map = new LinkedMultiValueMap<>();
map.add("photo", this.startMessagePhotoFileId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
HttpEntity> requestEntity = new HttpEntity<>(map, headers);
try {
restTemplate.exchange(
MessageFormat.format("{0}bot{1}/sendPhoto?chat_id={2}&caption={3}",
URL, botToken, chatId, this.startMessageText),
HttpMethod.POST,
requestEntity,
String.class);
} catch (Exception e) {
throw new TelegramFileUploadException();
}
}
public void uploadAudio(String chatId, ByteArrayResource value) {
LinkedMultiValueMap map = new LinkedMultiValueMap<>();
map.add("audio", value);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
HttpEntity> requestEntity = new HttpEntity<>(map, headers);
try {
restTemplate.exchange(
MessageFormat.format("{0}bot{1}/sendAudio?chat_id={2}", URL, botToken, chatId),
HttpMethod.POST,
requestEntity,
String.class);
} catch (Exception e) {
throw new TelegramFileUploadException();
}
}
public File getVoiceFile(String fileId) {
try {
return restTemplate.execute(
Objects.requireNonNull(getVoiceTelegramFileUrl(fileId)),
HttpMethod.GET,
null,
clientHttpResponse -> {
File ret = File.createTempFile(this.tempFileNamePrefix, ".ogg");
StreamUtils.copy(clientHttpResponse.getBody(), new FileOutputStream(ret));
return ret;
});
} catch (Exception e) {
throw new TelegramFileNotFoundException();
}
}
private String getVoiceTelegramFileUrl(String fileId) {
try {
ResponseEntity> response = restTemplate.exchange(
MessageFormat.format("{0}bot{1}/getFile?file_id={2}", URL, botToken, fileId),
HttpMethod.GET,
null,
new ParameterizedTypeReference>() {
}
);
return Objects.requireNonNull(response.getBody()).getResult().getFileUrl(this.botToken);
} catch (Exception e) {
throw new TelegramFileNotFoundException();
}
}
}
Удаляем ненужные файлы
Побочный продукт нашего бота — временные файлы .ogg и .mp3, располагающиеся в специальной директории операционной системы. Конечно, они будут удалены операционкой, но происходит это довольно редко, а нам они не нужны сразу после отправки — так почему бы их не почистить?
Создадим класс, поддерживающий работу по расписанию — за это отвечают аннотации EnableAsync
над классом и Scheduled
над методом.
Алгоритм работы простой — мы просматриваем все файлы во временной директории, отбираем те, что содержат префиксы, которые мы ранее добавили в названия наших аудио-файлов, и удаляем, если они не заняты другой (то есть нашей же) программой.
Метод deleteTempFiles()
запускается с периодичностью, определённой в cron-настройке в файле application.yaml
, сейчас — раз в 10 минут.
FileScheduler.java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileSystemException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@EnableAsync
@Component
public class FileScheduler {
Logger logger = LoggerFactory.getLogger(FileScheduler.class);
private final String incomingTempFileNamePrefix;
private final String outgoingTempFileNamePrefix;
public FileScheduler(@Value("${files.incoming}") String incomingTempFileNamePrefix,
@Value("${files.outgoing}") String outgoingTempFileNamePrefix) {
this.incomingTempFileNamePrefix = incomingTempFileNamePrefix;
this.outgoingTempFileNamePrefix = outgoingTempFileNamePrefix;
}
@Async
@Scheduled(cron = "${schedule.cron.delete-temp-files}")
public void deleteTempFiles() {
for (String path : getToDeletePathList()) {
try {
Files.deleteIfExists(Path.of(path));
} catch (FileSystemException e) {
logger.debug(e.getMessage());
} catch (IOException e) {
logger.error(e.getMessage());
}
}
}
private List getToDeletePathList() {
File dir = new File(System.getProperty("java.io.tmpdir"));
List tempFilePathList = new ArrayList<>();
for (File file : Objects.requireNonNull(dir.listFiles())){
if (file.isFile() && needToDelete(file.getName()))
tempFilePathList.add(file.getAbsolutePath());
}
return tempFilePathList;
}
private boolean needToDelete(String fileName) {
return fileName.contains(this.incomingTempFileNamePrefix) || fileName.contains(this.outgoingTempFileNamePrefix);
}
Создаём эндпоинт для тестирования
По опыту, дебаг Telegram-ботов становится проще и быстрее, если разделить его на два этапа — работоспособность приложения и внешние факторы.
Для этого создадим простейший REST-контроллер, возвращающий одну и ту же строку — если он работает, то приложение взлетело, и ошибку надо искать где-то в кишках взаимодействия с Telegram.
TestController.java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestController {
@GetMapping("/premium/test")
public String getTestMessage() {
return "I believe I can fly";
}
}
Запускаем локально
Нам нужен вебхук, и мы получим его, используя утилиту ngrok. Скачав и открыв его, отправляем команду ngrok http 5000
(или другой порт, если по каким-то причинам 5000 вам не нравится):
Получаем на 2 часа URL, который можем использовать как вебхук:
Вставляем его в applicatiom.yaml
в настройку telegram.webhook-path
, добавив в конце /premium
(такой эндпоинт в нашем контроллере).
Регистрируем вебхук в Telegram, формируя в строке браузера запрос вида:
https://api.telegram.org/bot<токен бота>/setWebhook?url=
… видим ответ:
{"ok":true,"result":true,"description":"Webhook was set"}
… и запускаем приложение в своей IDE.
Благодарность
Лучшему иллюстратору, киноману и доброму другу desvvt за соавторство идеи и оформление.