larasup / telegram
Laravel package for building Telegram bots with declarative routing, typed views, and keyboard builders
Requires
- php: ^8.2
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/queue: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- larasup/localization: ^2.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
README
Laravel package for building Telegram bots. Webhook-based, queue-driven, with declarative routing, typed views, keyboard builders, and full Telegram Bot API coverage.
Table of Contents
- Quick Start
- Installation
- Configuration
- Architecture
- Webhook
- Routing
- Controllers
- Views
- Keyboards
- Markdown
- Typed DTOs
- User Management
- Telegram API Client
- Rate Limiting
- Console Commands
- Models
- Events
- Testing
Requirements
- PHP 8.2+
- Laravel 10, 11, or 12
Installation
composer require larasup/telegram
Run migrations:
php artisan migrate
Publish the config:
php artisan vendor:publish --tag=telegram-config
Configuration
Environment variables:
TELEGRAM_BOT_TOKEN=123456:ABC-DEF # Bot token from @BotFather
TELEGRAM_BOT_USERNAME=MyBot # Bot username (without @)
TELEGRAM_SECRET_TOKEN=your-random-secret # Webhook secret (X-Telegram-Bot-Api-Secret-Token)
TELEGRAM_BOT_RESPONSE=true # Enable/disable bot responses
TELEGRAM_MODE=production # production / testing
TELEGRAM_NOTIFIABLE_ID= # Admin Telegram ID for debug info
TELEGRAM_LEAVE_CHANNELS=false # Auto-leave when added to a channel
Config file config/telegram.php:
return [
'table_prefix' => 'telegram_', // DB table prefix
'user_model' => App\Models\User::class, // Host app User model
'token' => env('TELEGRAM_BOT_TOKEN'),
'username' => env('TELEGRAM_BOT_USERNAME'),
'secret_token' => env('TELEGRAM_SECRET_TOKEN'),
'response' => env('TELEGRAM_BOT_RESPONSE', true),
'mode' => env('TELEGRAM_MODE', 'production'),
'leave_channels' => env('TELEGRAM_LEAVE_CHANNELS', false),
'notifiable_id' => env('TELEGRAM_NOTIFIABLE_ID'),
'base_messages' => [
'unknown' => 'telegram::base_messages.unknown',
],
'routes_file' => base_path('routes/telegram.php'),
'router_cache' => 'cache/telegram_routes.php',
];
Architecture
Request Flow:
Telegram → POST /webhook/{token}
→ TelegramController (creates RequestLog, dispatches job)
→ AnswerTelegramRequest job (queue: high)
→ TelegramRequestPreparator
→ Update DTO parsed from raw JSON
→ User resolution (find or create TelegramUser + User)
→ Locale setup from user settings
→ TelegramRequestDTO construction
→ Router::process() — finds handler from declared routes
→ YourController::method()
→ returns View (MessageView, PhotoView, ...)
→ wrapped into TelegramResponse (method + params)
→ TelegramClient::send(view)
→ SendTelegramRequest job (queue: high)
→ cURL to Telegram API
Key Components
| Component | Responsibility |
|---|---|
TelegramController | Webhook entry point, job dispatch |
Update | Typed DTO for incoming Telegram updates (47 types) |
TelegramRequestPreparator | User management, request parsing, route execution |
Router | Route registration and matching |
TelegramResponse | Wraps view/array into method + params pair |
TelegramView | Base class for typed responses (17 view classes) |
TelegramClient | Telegram API communication (33 methods) |
TelegramRequestDTO | Request data container with parsed command and route |
Controller | Base class for bot controllers |
Webhook
Set up the webhook URL with Telegram:
php artisan telegram:show_url
Register via API or use the client:
TelegramClient::setWebhook(
'https://your-domain.com/webhook/' . config('telegram.token'),
secretToken: config('telegram.secret_token'),
);
Webhook Security
When TELEGRAM_SECRET_TOKEN is set, every incoming request must carry the
matching X-Telegram-Bot-Api-Secret-Token header. Requests without it (or
with a wrong value) receive 403 Forbidden and are never processed. This
prevents third parties from sending fake updates to your webhook URL.
The webhook endpoint:
POST /webhook/{token}
Route is automatically registered with CSRF and session middleware excluded.
Routing
Declaring Routes
Routes are declared in routes/telegram.php (path configurable via
config('telegram.routes_file')). Two route types are supported:
use Larasup\Telegram\Facades\TelegramRoute;
// Forward routes — handle commands, button presses, and callbacks
TelegramRoute::make('base.start', [MainController::class, 'start']);
TelegramRoute::make('main.mainMenu', [MainController::class, 'mainMenu']);
TelegramRoute::make('settings.settings', [SettingsController::class, 'settings']);
// Reverse (back) routes — handle the "Назад" / "Back" reply button
TelegramRoute::back('base.back', [MainController::class, 'mainMenu']); // catch-all
TelegramRoute::back('settings.settings', [PersonalController::class, 'cabinet']); // Settings → Personal
TelegramRoute::back('settings.timeZone', [SettingsController::class, 'settings']); // Timezone → Settings
Route Types
| Method | Type | Lookup key | Description |
|---|---|---|---|
TelegramRoute::make($uri, $action) | Direct | section.command from button or callback | Forward navigation |
TelegramRoute::back($sourceUri, $action) | Reverse | lastCommand->type of the user | State-aware back navigation |
State-Aware Back Navigation
When a user presses the Back reply button (any text whose section/command
resolves to main.back or base.back), the package:
- Switches the request route type to Reverse.
- Looks up the handler by the user's current screen —
lastCommand->type— instead of the literal button URI. This means eachTelegramRoute::back()declaration says "when leaving screen X, run handler Y". - Falls back to the registered
'base.back'handler if no specific entry matches the source screen, or iflastCommandis missing entirely.
For this to work, every screen controller must record its identifier when the user enters it:
use Larasup\Telegram\Models\UserCommand;
class SettingsController extends Controller
{
public function settings(): TelegramResponse
{
// Mark the user as being on the "settings.settings" screen.
// UserCommand::create rejects any previous state automatically.
UserCommand::create($this->request->telegramUser, 'settings.settings');
return TelegramResponse::fromView(
MessageView::make(trans('messages.settings'))
->keyboard(SettingsKeyboard::menu()),
);
}
public function timeZone(): TelegramResponse
{
UserCommand::create($this->request->telegramUser, 'settings.timeZone');
// ...
}
}
With those state records in place, this routing table:
TelegramRoute::back('base.back', [MainController::class, 'mainMenu']);
TelegramRoute::back('personal.cabinet', [MainController::class, 'mainMenu']);
TelegramRoute::back('settings.settings', [PersonalController::class, 'cabinet']);
TelegramRoute::back('settings.timeZone', [SettingsController::class, 'settings']);
produces the navigation hierarchy:
Main Menu
└─ Personal Cabinet
└─ Settings
└─ Time Zone
A Back press from any screen walks one level up. From a screen with no
registered back handler (or no recorded state at all), the user is sent to
the 'base.back' catch-all — typically the main menu.
Why state-aware?
The reply button "Back" looks the same on every screen, but it should lead
to different parents depending on context. Naive routing matches by the
literal button URI and gives one handler for all back presses, forcing
manual dispatch inside it. State-aware reverse routing pushes that decision
into the router itself: each back() entry is a single, declarative arrow
in the navigation graph.
Route Caching
Cache routes for production:
php artisan telegram:route-cache # Cache declared routes
php artisan telegram:route-clear # Clear route cache
Controllers
Extend the base Controller class. The TelegramRequestDTO is available via $this->request.
use Larasup\Telegram\Routing\Controller;
use Larasup\Telegram\Keyboard\Keyboard;
use Larasup\Telegram\Keyboard\InlineButton;
use Larasup\Telegram\Views\MessageView;
class MainController extends Controller
{
public function start(): MessageView
{
return MessageView::make('Welcome! Choose an option:')
->markdown()
->keyboard(
Keyboard::inline(
Keyboard::row(
InlineButton::callback('Profile', '/profile'),
InlineButton::callback('Settings', '/settings'),
),
)
);
}
}
Return Types
Controllers can return a View, a TelegramResponse, or a plain array:
// View (recommended) — typed, fluent, knows its Telegram API method
public function start(): MessageView
{
return MessageView::make('Hello!')->markdown();
}
// Photo, document, location, etc. — same pattern
public function photo(): PhotoView
{
return PhotoView::make($fileId)->caption('Check this out')->spoiler();
}
// Array (backward compatible) — sent as sendMessage
public function legacy(): array
{
return ['text' => 'Hello!', 'parse_mode' => 'Markdown'];
}
// TelegramResponse (full control)
public function custom(): TelegramResponse
{
return TelegramResponse::fromView(
LocationView::make(55.75, 37.61)->live(3600)
);
}
The API method (sendMessage, sendPhoto, editMessageText, ...) is determined automatically from the view — no need to specify it manually.
See Views for the full list of available views.
Accessing Request Data
class MyController extends Controller
{
public function handle(): MessageView
{
$update = $this->request->update; // Update DTO
$user = $this->request->telegramUser; // TelegramUser model
$callback = $this->request->callback; // CallbackData (if callback)
// Typed access to update data
$from = $update->from(); // UserData
$chat = $update->chat(); // ChatData
$text = $update->text(); // ?string
// Media from message
$photo = $update->message?->largestPhoto(); // ?PhotoSizeData
$location = $update->message?->location; // ?LocationData
$document = $update->message?->document; // ?DocumentData
// Callback data
$id = $callback?->data['id'] ?? null;
return MessageView::make('Hello, ' . $from->firstName . '!');
}
}
Answering Callback Queries
When a controller handles an inline button click, the package automatically
calls answerCallbackQuery after the controller returns — this stops the
loading spinner on the button. By default the answer carries no text, so
the user sees no popup.
To raise a toast (transient banner at the top of the screen) or an
alert (modal popup), call $this->answerCallback() from the controller:
class CartController extends Controller
{
public function add(): TelegramResponse
{
// ... do work ...
// Brief toast — disappears after a second
$this->answerCallback('✅ Добавлено в корзину');
return TelegramResponse::empty();
}
public function purchase(): TelegramResponse
{
// Modal alert — user must dismiss it
$this->answerCallback(
text: '🚧 Покупка временно недоступна',
showAlert: true,
);
return TelegramResponse::empty();
}
public function lastPage(): TelegramResponse
{
// Pagination edge case: tell the user there is nothing further
$this->answerCallback('Это последняя страница');
return TelegramResponse::empty();
}
}
The payload is stored on TelegramRequestDTO::$callbackAnswer and forwarded
to Telegram by the request preparator. Returning TelegramResponse::empty()
keeps the original message intact — useful when the only feedback is the
toast itself. Of course you can also combine answerCallback() with an
edited or new message.
Note: Telegram allows exactly one answer per callback query. Calling
answerCallback()from the controller is the only way to set the text — do not invokeTelegramClient::answerCallbackQuerydirectly, it would conflict with the framework's auto-answer.
Typed DTOs
All incoming Telegram data is parsed into typed, readonly DTOs. No more $data['message']['from']['id'].
Update Types (in Larasup\Telegram\DTOs\Api)
| DTO | Telegram Type | Access via Update |
|---|---|---|
Update | Update | top-level |
MessageData | Message | $update->message |
CallbackQueryData | CallbackQuery | $update->callbackQuery |
InlineQueryData | InlineQuery | $update->inlineQuery |
ChosenInlineResultData | ChosenInlineResult | $update->chosenInlineResult |
ChatMemberUpdatedData | ChatMemberUpdated | $update->myChatMember, $update->chatMember |
ChatJoinRequestData | ChatJoinRequest | $update->chatJoinRequest |
ShippingQueryData | ShippingQuery | $update->shippingQuery |
PreCheckoutQueryData | PreCheckoutQuery | $update->preCheckoutQuery |
PollData | Poll | $update->poll |
PollAnswerData | PollAnswer | $update->pollAnswer |
Message Content Types
| DTO | Access via Message |
|---|---|
UserData | $msg->from |
ChatData | $msg->chat |
PhotoSizeData | $msg->photo (array), $msg->largestPhoto() |
DocumentData | $msg->document |
VideoData | $msg->video |
AudioData | $msg->audio |
VoiceData | $msg->voice |
VideoNoteData | $msg->videoNote |
AnimationData | $msg->animation |
StickerData | $msg->sticker |
LocationData | $msg->location |
VenueData | $msg->venue |
ContactData | $msg->contact |
DiceData | $msg->dice |
StoryData | $msg->story |
MessageEntityData | $msg->entities (array) |
InvoiceData | $msg->invoice |
SuccessfulPaymentData | $msg->successfulPayment |
WebAppDataData | $msg->webAppData |
Service Message Types
| DTO | Access via Message |
|---|---|
ForumTopicData | $msg->forumTopicCreated, $msg->forumTopicEdited |
UsersSharedData / SharedUserData | $msg->usersShared |
ChatSharedData | $msg->chatShared |
ProximityAlertData | $msg->proximityAlertTriggered |
VideoChatData | $msg->videoChatStarted, $msg->videoChatEnded, etc. |
WriteAccessAllowedData | $msg->writeAccessAllowed |
Helper Types
| DTO | Used by |
|---|---|
ChatMemberData | ChatMemberUpdatedData |
ChatInviteLinkData | ChatMemberUpdatedData, ChatJoinRequestData |
ChatPermissionsData | TelegramClient::restrictChatMember() |
LinkPreviewOptionsData | MessageData |
ReplyParametersData | MessageData |
OrderInfoData / ShippingAddressData | Payment DTOs |
InputMediaData | TelegramClient::sendMediaGroup() |
BotCommandData | TelegramClient::setMyCommands() |
PollOptionData | PollData |
Message Helpers
$msg->hasText(); // has text content
$msg->isCommand(); // starts with /
$msg->hasMedia(); // photo, video, document, audio, voice, etc.
$msg->isMediaGroup(); // part of an album
$msg->isServiceMessage(); // new members, forum topic, video chat, etc.
$msg->isPayment(); // successful payment
$msg->largestPhoto(); // biggest PhotoSize from photo array
User Management
When a new Telegram user sends a message, the package automatically:
- Creates an internal
Usermodel (configurable viaconfig('telegram.user_model')) - Creates a
TelegramUserlinked to it - Stores user's language preference in
UserSettings - Dispatches
TelegramUserCreatedevent - Authenticates the user via
Auth::login()
User Settings
// Read
$locale = $telegramUser->getSettings('language_code', 'en');
// Write
$telegramUser->setSettings('language_code', 'ru');
// Remove (drops the row from telegram_user_settings — `getSettings` will
// return the default value next time)
$telegramUser->removeSettings('favorite_bookmaker_id');
// With enum keys
$telegramUser->getSettings(UserSettingEnum::LanguageCode);
$telegramUser->setSettings(UserSettingEnum::LanguageCode, 'ru');
$telegramUser->removeSettings(UserSettingEnum::FavoriteBookmakerId);
Note: the
valuecolumn isNOT NULL, so you can't writenullto clear a setting — useremoveSettings()to drop the row instead.
User Command State
UserCommand records the user's "current screen" in telegram_user_commands.
It serves two purposes:
- Multi-step input — when a user sends text that does not match any
declared button, the router falls through to
lastCommand->typeso the active screen handler can interpret the input (typing a number, choosing from a custom list, etc.). - Back navigation —
TelegramRoute::back()looks up handlers bylastCommand->type, see State-Aware Back Navigation.
use Larasup\Telegram\Models\UserCommand;
// On screen entry: record where the user is (rejects any previous state)
UserCommand::create($telegramUser, 'settings.settings');
// Anywhere: read the current screen
$user->lastCommand?->type; // 'settings.settings'
$user->lastCommand?->data; // optional array payload
// Force-clear all enabled commands (rarely needed — `create` does it for you)
UserCommand::rejectAllCommands($telegramUser);
Pick stable identifiers — by convention section.command matching your
forward routes, e.g. 'settings.settings', 'settings.timeZone',
'personal.cabinet'.
Telegram API Client
Sending Views
use Larasup\Telegram\Client\TelegramClient;
use Larasup\Telegram\Views\MessageView;
TelegramClient::send(
MessageView::make('Hello!')->chatId($chatId)->markdown(),
);
Chat Actions
use Larasup\Telegram\Enums\ChatActionEnum;
TelegramClient::sendChatAction($chatId, ChatActionEnum::TYPING);
TelegramClient::sendChatAction($chatId, ChatActionEnum::UPLOAD_PHOTO);
Message Operations
TelegramClient::forwardMessage($toChatId, $fromChatId, $messageId);
TelegramClient::copyMessage($toChatId, $fromChatId, $messageId);
TelegramClient::deleteMessage($chatId, $messageId);
TelegramClient::deleteMessages($chatId, [1, 2, 3]);
Media Groups
use Larasup\Telegram\DTOs\Api\InputMediaData;
TelegramClient::sendMediaGroup($chatId, [
InputMediaData::photo('file_id_1', caption: 'First')->toArray(),
InputMediaData::photo('file_id_2', caption: 'Second')->toArray(),
]);
Moderation
TelegramClient::banChatMember($chatId, $userId);
TelegramClient::unbanChatMember($chatId, $userId);
TelegramClient::restrictChatMember($chatId, $userId, $permissions->toArray());
TelegramClient::promoteChatMember($chatId, $userId, ['can_pin_messages' => true]);
TelegramClient::setChatAdministratorCustomTitle($chatId, $userId, 'Moderator');
Pin/Unpin
TelegramClient::pinChatMessage($chatId, $messageId);
TelegramClient::unpinChatMessage($chatId, $messageId);
TelegramClient::unpinAllChatMessages($chatId);
Live Location
TelegramClient::editMessageLiveLocation(48.85, 2.35, chatId: $chatId, messageId: $msgId);
TelegramClient::stopMessageLiveLocation(chatId: $chatId, messageId: $msgId);
Inline Mode
TelegramClient::answerInlineQuery($queryId, $results, cacheTime: 300);
Callbacks
TelegramClient::answerCallbackQuery($callbackId);
TelegramClient::answerCallbackQuery($callbackId, text: 'Done!', showAlert: true);
Bot Settings
use Larasup\Telegram\DTOs\Api\BotCommandData;
TelegramClient::setMyCommands([
new BotCommandData('start', 'Start the bot'),
new BotCommandData('help', 'Show help'),
]);
TelegramClient::deleteMyCommands();
TelegramClient::setMyName('My Bot');
TelegramClient::setMyDescription('A useful bot');
TelegramClient::setMyShortDescription('A useful bot for everyone');
Webhook Management
TelegramClient::setWebhook('https://example.com/webhook/TOKEN', secretToken: 'secret');
TelegramClient::deleteWebhook(dropPendingUpdates: true);
Invite Links
TelegramClient::exportChatInviteLink($chatId);
TelegramClient::createChatInviteLink($chatId, name: 'Promo', memberLimit: 100);
Other
TelegramClient::leaveChat($chatId);
TelegramClient::getUserProfilePhotos($userId);
TelegramClient::getFile($filePath); // returns file contents
Full Method Coverage
The TelegramMethodEnum covers all 120 methods of the Telegram Bot API: messages, editing, stickers, inline mode, payments, games, chat management, forum topics, bot settings, webhook, and passport.
Rate Limiting
The package protects against Telegram's rate limits on two levels:
1. Proactive throttling
SendTelegramRequest checks a sliding-window rate limiter before making
the HTTP call. If the limit is reached, the job is released back to the queue
with an appropriate delay — no request is wasted.
Telegram enforces three independent limits:
| Scope | Limit | Description |
|---|---|---|
| Global | 30 msg/sec | Total messages across all chats |
| Private chat | 1 msg/sec | Messages to the same user |
| Group / supergroup / channel | 20 msg/min | Messages to the same group |
All three are checked; the longest required delay wins.
2. Reactive retry on 429
If Telegram still returns HTTP 429 Too Many Requests (e.g. limits changed,
or multiple workers race), the job reads retry_after from the response and
releases itself for exactly that many seconds. This does not count as a
failed attempt — the job will not exhaust its retry budget due to throttling.
Server errors (5xx) and network failures are retried with exponential backoff (5 → 15 → 30 s). Client errors (4xx except 429) are not retried.
Configuration
All limits are configurable via config/telegram.php or .env:
TELEGRAM_RATE_LIMITS=true # Enable/disable proactive throttling
TELEGRAM_RATE_LIMIT_GLOBAL=30 # Messages per second (all chats)
TELEGRAM_RATE_LIMIT_CHAT=1 # Messages per second (one private chat)
TELEGRAM_RATE_LIMIT_GROUP=20 # Messages per minute (one group)
// config/telegram.php
'rate_limits' => [
'enabled' => env('TELEGRAM_RATE_LIMITS', true),
'global_per_second' => (int) env('TELEGRAM_RATE_LIMIT_GLOBAL', 30),
'chat_per_second' => (int) env('TELEGRAM_RATE_LIMIT_CHAT', 1),
'group_per_minute' => (int) env('TELEGRAM_RATE_LIMIT_GROUP', 20),
],
Set TELEGRAM_RATE_LIMITS=false to disable proactive throttling entirely.
The 429 retry_after handling always remains active regardless of this setting.
Note: The rate limiter uses the cache store (prefers
redisormemcached). With thearrayorfiledriver it works per-process only and cannot coordinate across multiple queue workers.
Console Commands
| Command | Description |
|---|---|
telegram:listen | Long-polling loop for development — see docs/long-polling.md. Supports --timeout=N and --force. |
telegram:route-cache | Cache bot routes for production |
telegram:route-clear | Clear cached routes |
telegram:clear_logs | Delete request logs older than 30 days |
telegram:show_url | Display the webhook URL |
telegram:translations-make | Sync button translations from lang files into the telegram_buttons table |
Models
| Model | Table | Description |
|---|---|---|
TelegramUser | telegram_users | Telegram user linked to internal User |
UserCommand | telegram_user_commands | Tracks current command state |
UserSettings | telegram_user_settings | Key-value user settings |
RequestLog | telegram_request_logs | Webhook request/response logs |
CallbackData | telegram_callback_data | Stored callback payloads (crc32b hash) |
Button | telegram_buttons | Localizable bot buttons |
UserQuery | telegram_user_queries | User query deduplication |
Events
| Event | Dispatched When |
|---|---|
TelegramUserCreated | New Telegram user is registered |
Listen in your ServiceProvider:
Event::listen(TelegramUserCreated::class, SendWelcomeNotification::class);
Testing
The package ships with two helpers that make controller-level tests fluent and avoid hand-rolling Telegram API payloads.
Update factories
Update::fakeMessage() and Update::fakeCallback() build wire-format Update
objects identical to what Telegram would deliver. The callback variant
internally creates the matching CallbackData row so that buildRoute()
resolves the callback hash exactly as it does in production:
use Larasup\Telegram\DTOs\Api\Update;
// Plain text message — useful for testing reply-keyboard handlers
$update = Update::fakeMessage('Главное меню', fromId: 12345);
// Inline button click — pass the command and its data payload
$update = Update::fakeCallback('settings.setBookmaker', ['id' => 5, 'page' => 1]);
Response assertions
TelegramResponse mixes in the Larasup\Telegram\Testing\AssertableResponse
trait. Every assertion returns $this, so they chain naturally:
$response
->assertSent() // method === sendMessage
->assertEdited() // method === editMessageText
->assertEmpty()
->assertText('exact text')
->assertTextContains('substring')
->assertTextDoesNotContain('forbidden')
->assertParseMode('Markdown')
->assertChatId(12345)
->assertMessageId(7)
->assertHasButton('🏠 Главное меню') // any keyboard kind
->assertNoButton('Removed button')
->assertHasReplyKeyboard()
->assertHasInlineKeyboard()
->assertHasInlineCallback('settings.setBookmaker', ['id' => 5, 'page' => 1]);
assertHasInlineCallback() resolves the inline button's callback_data
hash via CallbackData::byHash() and matches both the command and the
optional stored payload. Helpful for verifying that pagination/navigation
buttons fire the right route.
Putting it together
A typical controller test looks like this:
public function testSetBookmakerSavesSelectionAndShowsCheckmark(): void
{
$update = Update::fakeCallback('settings.setBookmaker', ['id' => 5, 'page' => 1]);
$this->callController(SettingsController::class, 'setBookmaker', $this->user, $update)
->assertEdited()
->assertTextContains('Букмекер указан: bet365')
->assertHasButton('✅ bet365 (0)')
->assertHasInlineCallback('settings.getBookmakerPage', ['page' => 1]);
$this->assertSame(5, (int) $this->user->fresh()->getSettings(UserSettingEnum::FavoriteBookmakerId));
}
The host application is responsible for callController() (or any
equivalent thin wrapper that instantiates a controller with a built
TelegramRequestDTO) — see the project's tests/Stubs/Telegram/
directory for an example.
License
MIT