Skip to content

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 BotInlineQueryHandled

See 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:

PropertyDescription
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?42

Helpers: 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=7

Helpers: 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 a booted() 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:

  1. Any active user state is cleared (cancelOldProcess)
  2. The key's handle() runs
  3. 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 in encodeCallback()
  • $perm — minimum BotUser.power required (Roles enum)
  • 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): void

StateAnswers ​

Handle text/media input while bot_users.state is set. Same structure as CallbackQueries but:

  • Params are named (from query string)
  • $allowedFields controls 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 message

TelegramResponse supports:

  • send() — new message
  • update() — edit callback message
  • answer() — toast on callback query
  • messageMetaModel() — 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 message
  • lockAction() — replace inline keyboard with a lock button
  • continueAction() — unlock the message in place, restoring its original keyboard
  • revertAction() — edit message back to original content
  • updateAndContinueAction(TelegramResponse) — replace the content in place, and remember it as what a later revert or unlock restores
  • deleteMessage() — 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:

  1. BotUser.power >= $power
  2. User is the bot owner
  3. User matches config('tbe-essence.developer.peer_id')

Roles enum (src/Enums/Roles.php):

  • ADMIN = 100
  • MODERATOR = 10
  • MEMBER = 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_member update the instant a user blocks/unblocks the bot (instant but lossy — the webhook is re-set with drop_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 them

BotUserStatus::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()):

StateStored onMeaning
activebot_users.statusTelegram delivers
blockedbot_users.statusUser blocked the bot
unreachablebot_users.statusChat can't be opened (never started the bot, "chat not found")
deactivatedtelegram_users.deactivated_atThe 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):

  1. Commands (/start, etc.)
  2. ReplyKeys (keyboard button text match)
  3. StateAnswers (if bot_users.state is set)

If none match → "request is invalid" response.

For callback queries:

  • CallbackQueryBus only (state is not checked)