Appearance
Architecture
Current version: v0.12.0
Overview
Telegram Bot Essentials routes every webhook update through a small set of buses. Each bus maps an encoded string (or user state) to a class method with dependency injection for Eloquent models.
Telegram Update
│
▼
TelegramBotAuthentication (middleware)
• Resolve bot tenant
• Verify secret_token
• Load Update into wHook()
• Find or create BotUser
│
▼
TelegramWebhookController
│
├── fires BotUpdateReceived
│
├── message + starts with "/" → Commands → fires BotCommandHandled
│ └── /start <x> → fires BotDeepLinkReceived
├── message + keyboard text → ReplyKeyBus → fires BotReplyKeyHandled
├── message + user has state → StateAnswerBus → fires BotStateAnswerHandled
├── nothing matched → fires BotUpdateUnhandled
├── callback_query → CallbackQueryBus → fires BotCallbackQueryHandled
└── inline_query → InlineQueryBus → fires BotInlineQueryHandledSee Events & Listeners for the full event reference.
Source: src/Http/Controllers/TelegramWebhookController.php
The Webhook Context (wHook())
wHook() returns a request-scoped singleton (TelegramBotEssentials\Essence\Support\Webhook) holding:
| Property | Description |
|---|---|
api() | Telegram Api instance for the current bot |
update() | Current Telegram Update object |
bot() | Current Bot model (tenant) |
user() | Current BotUser model |
requestState() | Snapshot of bot_users.state when the user was loaded |
peerId() | Telegram user/chat ID from the update |
Use wHook() anywhere in handlers instead of passing context through every method.
The context can also be exported/imported for queued jobs via exportContext() and importContext() (src/Support/Webhook.php).
Encoding Formats
Two parallel encoding schemes exist. Both use TYPE#method with optional parameters.
Callback data (inline buttons)
Used in callback_data on inline keyboard buttons. Hard limit: 64 bytes (Telegram API).
BOTADMNS#add_admin
BOTADMNS#delete_admin?42Helpers: encodeCallback(), decodeCallback() in src/helpers.php.
Params are positional (not named) — resolved by index into handler method parameters.
Answer state (user input routing)
Stored in bot_users.state. No Telegram size limit (lives in your database).
BOTADMNS#add_admin?message_meta_id=12
PRODUCTSMANAGEMENT#add?target_field=title&state_data=7Helpers: encodeAnswerState(), decodeAnswerState() in src/helpers.php.
Params are named query-string key/value pairs — resolved by name into handler method parameters.
Handler Registration
Since v0.12.0, handlers are registered once per worker boot in TelegramBotServiceProvider, not rescanned on every request:
- Essence's own built-ins register in
boot()(lowest precedence). - Companion packages register in their own service provider
boot(). - The consuming app's
app/Telegram/**is scanned last, in abooted()callback, so an app handler wins any name collision with essence or a companion.
This works because handlers hold translation keys and resolve them lazily via __() at read time (see Extending → handler labels), so one registered instance is correct in every locale — safe to keep alive across requests on a persistent (Octane) worker.
Source: src/TelegramBotServiceProvider.php (registerPackageHandlers(), registerAppHandlers())
Building Blocks
Commands
Handled by CommandBus, auto-discovered from app/Telegram/Commands/{Admin,Member} — no config registration needed. See Commands for the full reference, including triggering a command programmatically via commandBus()->route().
Commands take priority over everything else. When a command runs, any active user state is cancelled first.
ReplyKeys
Keyboard buttons shown at the bottom of the chat. Registered via config/tbe-essence.php → keyboard and/or loadReplyKeys().
When a ReplyKey is pressed:
- Any active user state is cleared (
cancelOldProcess) - The key's
handle()runs - The previous state's
cancel()method is invoked on the StateAnswer
While a user has an active state, the keyboard shows Cancel Process instead of the normal menu.
CallbackQueries
Handle inline button presses. Each class declares:
$type— uppercase identifier used inencodeCallback()$perm— minimumBotUser.powerrequired (Rolesenum)- Methods matching callback method names (snake_case in encoding, resolved to camelCase)
Model parameters are resolved by position from callback params:
php
// callback_data: BOTADMNS#delete_admin?42
public function deleteAdmin(BotUser $botUser): voidStateAnswers
Handle text/media input while bot_users.state is set. Same structure as CallbackQueries but:
- Params are named (from query string)
$allowedFieldscontrols which message fields trigger the handler (text,photo,document)- Optional
cancel()method for cleanup when the flow is interrupted
See State & StateData for the full input-flow pattern.
Features
Static classes that return TelegramResponse objects. They build message text and inline keyboards but do not handle updates directly.
Features are called from CallbackQueries or StateAnswers:
php
BotAdminsFeature::menu()->update(); // edit current callback message
BotAdminsFeature::menu()->send(); // send new messageTelegramResponse supports:
send()— new messageupdate()— edit callback messageanswer()— toast on callback querymessageMetaModel()— attach MessageMeta to a model
Source: src/Telegram/TelegramResponse.php, src/Telegram/Features/BotAdminsFeature.php
MessageMeta
Tracks a Telegram message so it can be locked, reverted, or updated during multi-step flows.
Common operations:
makeWithCurrentMessage()— snapshot the callback messagelockAction()— replace inline keyboard with a lock buttoncontinueAction()— unlock the message in place, restoring its original keyboardrevertAction()— edit message back to original contentupdateAndContinueAction(TelegramResponse)— replace the content in place, and remember it as what a later revert or unlock restoresdeleteMessage()— delete it; a message Telegram will no longer delete (over 48 hours old) has its inline keyboard stripped instead
Nothing here depends on deleting a message, because Telegram refuses to delete one older than 48 hours: a flow can sit open for days and still unlock and update its message.
MessageMeta morphs to an action model (e.g. StateData, an order, a product).
Source: src/Models/MessageMeta.php
StateData
Separate JSON storage for multi-step forms and cross-app payloads. See State & StateData.
Multi-Tenancy
Each Bot is a Stancl Tenancy tenant (bots table). The webhook route uses InitializeTenancyByPath middleware with {bot} as the tenant identifier (unique_id).
BelongsToTenant::$tenantIdColumn is set to bot_id, so models like BotUser are scoped per bot.
Access Control
hasAccess(?int $power) checks:
BotUser.power >= $power- User is the bot owner
- User matches
config('tbe-essence.developer.peer_id')
Roles enum (src/Enums/Roles.php):
ADMIN = 100MODERATOR = 10MEMBER = 0
Bot User Reachability
Since v0.12.0 essence tracks whether Telegram will actually deliver to a given BotUser, folding two signals into one state:
- Push: a
my_chat_memberupdate the instant a user blocks/unblocks the bot (instant but lossy — the webhook is re-set withdrop_pending_updates). - Reactive: the error Telegram returns on a failed send — the only way a deleted account is ever discovered.
Both funnel through the BotUserStatus service (helper: botUserStatus()), so one transition produces exactly one persisted change and one BotUserStatusChanged event, wherever it was learned:
php
botUserStatus()->reportFailure($botUser, $telegramException); // classify a send error
botUserStatus()->reportSuccess($botUser); // a send worked → active
botUserStatus()->markActive($botUser); // an update arrived from themBotUserStatus::classify() matches on an allowlist of error descriptions, never the HTTP code alone — a 400 "can't parse entities" from malformed broadcast HTML hits every recipient and must not mark the whole user base unreachable.
The four reachability states (BotUser::reachability()):
| State | Stored on | Meaning |
|---|---|---|
active | bot_users.status | Telegram delivers |
blocked | bot_users.status | User blocked the bot |
unreachable | bot_users.status | Chat can't be opened (never started the bot, "chat not found") |
deactivated | telegram_users.deactivated_at | The Telegram account itself is gone — global to the peer, not per-bot |
Query scopes on BotUser: reachable() (active and not deactivated), withStatus($status), deactivated(), notSuspended(). reachable() deliberately says nothing about suspension — use ->reachable()->notSuspended() when both matter.
User Management surfaces these as list filters and a header block; Announcements skips non-reachable users and feeds send outcomes back through botUserStatus().
Error Handling
Uncaught exceptions in the webhook are handled by exceptionHandler(). exceptionReport() additionally sends trace files and update JSON to config('tbe-essence.bug_report.telegram_chat_id') when configured.
Request Processing Priority
For messages (in order):
- Commands (
/start, etc.) - ReplyKeys (keyboard button text match)
- StateAnswers (if
bot_users.stateis set)
If none match → "request is invalid" response.
For callback queries:
- CallbackQueryBus only (state is not checked)