Appearance
User Management
Current version: v0.0.16
telegram-bot-essentials/user-management (namespace TelegramBotEssentials\UserManagement) gives admins a browsable, sortable list of bot users, a per-user detail screen built from pluggable sections, and automatic interaction logging. Other modules (notably User Wallet) extend this list by registering their own sort or section rather than forking the admin UI.
Installation
bash
composer require telegram-bot-essentials/user-management
php artisan migratePublish config if you want to change the default sort:
bash
php artisan vendor:publish --tag=tbe-user-management-configphp
// config/tbe-user-management.php
return [
'default_sort' => 'last_interaction',
'default_sort_direction' => 'desc',
'default_filter' => 'all', // since v0.0.15 — see BotUserFilters below
];Sorting the user list — BotUserSorts
Helper: botUserSorts(). A keyed registry of TelegramBotEssentials\UserManagement\DTOs\BotUserSort. Three sorts ship by default (registered in the service provider's boot()): last_interaction, created_at, username.
php
class BotUserSort
{
public function __construct(
public string $key,
public string $label,
public Closure $apply, // (Builder $query, string $direction) => Builder
public ?Closure $display = null, // (BotUser $user) => string, shown in the list
public bool|Closure $active = true,
) {}
}Registering a new sort from your own package or app:
php
botUserSorts()->addSort(new BotUserSort(
key: 'wallet_balance',
label: __('my-package::bot_users.sorts.wallet_balance'),
apply: fn ($query, $direction) => $query
->leftJoin('bot_user_wallets', /* ... */)
->orderBy('bot_user_wallets.balance', $direction)
->select('bot_users.*'),
display: fn (BotUser $user) => $user->wallet_balance ?? '0',
active: fn () => settings()->get('billing.user_wallet.status'),
));active must be a Closure for conditional sorts
getSorts()/getSort() filter out any sort whose isActive() returns false, and isActive() evaluates active at call time — inside a request, after wHook()->bot() is resolved. If a sort's availability depends on a per-bot Settings value, always pass a Closure (not a plain bool) so the check happens per-request, not once at application boot. This is also why sort-building queries that JOIN another table (like username and wallet_balance above) should select('bot_users.*') explicitly — otherwise joined columns leak into the hydrated BotUser model.
API
php
botUserSorts()->getSorts(); // Collection<string, BotUserSort> — active only
botUserSorts()->getSort('username'); // ?BotUserSort — null if missing or inactive
botUserSorts()->getDefaultKey(); // config('tbe-user-management.default_sort') if active, else first active key
botUserSorts()->resolve($key); // $key if valid+active, else getDefaultKey()
botUserSorts()->next($currentKey); // cycles to the next active sort key, for a "change sort" button
botUserSorts()->resolveDirection($dir); // $dir if 'asc'/'desc', else config default
botUserSorts()->toggleDirection($dir); // flips asc/desc
botUserSorts()->directionIndicator($dir); // '⬆️' / '⬇️'
botUserSorts()->apply($key, $query, $direction); // resolves + applies in one callBotUsersFeature uses exactly this API to render the list screen and the "change sort" / "change direction" inline buttons, so a newly registered sort appears in the cycle automatically with zero UI code.
Filtering the user list — BotUserFilters
Since v0.0.15. Helper: botUserFilters(). A registry of TelegramBotEssentials\UserManagement\DTOs\BotUserFilter, mirroring BotUserSorts — single-select (one filter active at a time). The active filter key is kept in the message's nav state (navState()), not in callback_data, which is already near Telegram's 64-byte ceiling once page + sort + direction are in it; losing it to cache eviction just resets the list to all.
php
class BotUserFilter
{
public function __construct(
public string $key,
public string|Closure $label, // resolved per read — pass a Closure for i18n
public ?Closure $apply = null, // (Builder $query) => Builder; null = no constraint (that's what `all` is)
public bool|Closure $active = true,
) {}
}The package ships the reachability filters out of the box — all, reachable, blocked, unreachable, deleted account — built on essence's reachability scopes. The failure states are kept separate rather than one "cannot reach" bucket because the admin response differs: a block is the user's choice, unreachable usually means they never started the bot, a deleted account is gone for good. Unreachable users are also marked inline in the list itself, so they're visible during normal browsing, not only behind the filter.
API mirrors sorts: getFilters() (active only), getFilter($key), getDefaultKey() (config('tbe-user-management.default_filter'), default all), resolve($key), apply($key, $query).
List header blocks — UserManagementStats
Since v0.0.15. Helper: userManagementStats(). The list header itself now reports the state of the user base in four compact lines — growth, reachability, activity, staff — from a single aggregate over bot_users joined to telegram_users (so deactivation outranks the per-bot status column and the four reachability numbers stay disjoint).
Other packages append their own blocks so things User Management has no business knowing about — wallet balances, what's been paid for — still render next to the user counts:
php
class UserStat
{
public function __construct(
public string $key,
public int $order,
public Closure $content, // () => ?string — the fully rendered block, or null to print nothing
public bool|Closure $active = true,
) {}
}
userManagementStats()->addStat(new UserStat(
key: 'wallet_totals',
order: 20,
content: fn () => /* render your own line(s) */,
active: fn () => (bool) settings()->get('billing.user_wallet.status'),
));Each package renders its own line (rather than handing over label/value pairs) because the content differs too much to share a formatter — money through the currency service, counts through number_format, wording from each package's own lang files. Billing and User Wallet both register a block this way.
Per-user detail screen — UserManagementSections
Helper: userManagementSections(). Lets other packages contribute a block to the per-user admin detail view (e.g. "show this user's wallet balance and a top-up button") without touching this package's Feature classes.
php
class UserSection
{
public function __construct(
public string $key,
public int $order, // display order, ascending
public SectionMode $mode, // BUTTON | INLINE
public Closure $label, // (BotUser $user) => string
public ?Closure $target = null, // required for BUTTON mode: (BotUser $user) => string (callback_data)
public ?Closure $content = null, // required for INLINE mode: (BotUser $user) => array (extra message content)
public bool|Closure $active = true,
) {}
}SectionMode::BUTTON adds a labeled inline button (its target closure supplies the callback_data, typically encodeCallback(...) into your own package's CallbackQuery); SectionMode::INLINE instead injects content directly into the detail message body. The constructor enforces this at registration time — a BUTTON section without target, or an INLINE section without content, throws immediately.
php
userManagementSections()->addSection(new UserSection(
key: 'wallet',
order: 10,
mode: SectionMode::INLINE,
label: fn (BotUser $user) => __('tbe-user-wallet::bot_users.sections.wallet.label'),
content: fn (BotUser $user) => ['balance' => $user->wallet?->balance ?? 0],
active: fn (BotUser $user) => settings()->get('billing.user_wallet.status'),
));Registering the same key twice throws TbeException — section keys must be globally unique across all installed packages. getSectionsFor($user) returns only the sections whose active($user) (or plain bool) resolves true, sorted by order.
Automatic interaction logging — BotUserAction
The package listens to Essence's BotUpdateReceived event (Listeners\LogBotInteractions, wired in Providers\EventServiceProvider) and records a row into bot_user_actions for every update type a user can generate — no opt-in required once the package is installed:
| Column | Description |
|---|---|
bot_user_id | Who did it |
update_type | The Telegram update type (see table below) |
state | The resolved handler, as TYPE->method — only set for message/callback_query, since the other update types aren't part of the FSM state machine those two use |
action | A type-appropriate summary of what happened |
Every update type Telegram can send is logged, each with its own action summary:
update_type | action contains |
|---|---|
message | The message text/caption |
callback_query | The pressed inline button's label |
edited_message | The edited text/caption |
inline_query | The typed query string |
chosen_inline_result | {query} -> {resultId}, or just resultId if the query was empty |
shipping_query | The invoice payload |
pre_checkout_query | {payload} ({amount} {currency}) |
poll_answer | options: {optionIds}, or [retracted vote] if the user withdrew their vote |
my_chat_member / chat_member | {oldStatus} -> {newStatus} |
chat_join_request | The requester's bio |
Any update type not in this table (there currently are none left unhandled) falls through to mixedDebugMessage() instead of being recorded.
Since v0.0.15, reachability transitions are logged by a separate listener, LogBotUserStatusChanges, on essence's BotUserStatusChanged event rather than the my_chat_member branch of LogBotInteractions. That covers blocks discovered by a failed send too — where there is no update to log — and guarantees one history row per actual change.
History-navigation actions (paging back and forth through the action-history UI itself: userActionsHistory, allActionsHistory, actionsPage, allActionsPage, actionsSetPage, allActionsSetPage) are filtered out via BotUserAction::isHistoryNavigation() so browsing history doesn't pollute the history. This filter only applies to message/callback_query, the only types that carry a state.
Admin UI
BotUsersQuery / BotUsersAnswer / BotUsersFeature / BotUsersKey provide the list screen (search, sort, paginate) and the per-user detail screen (sections + action history) — install the package, and admins get a working "Users" menu item immediately.
How User Wallet extends this module
User Wallet is the canonical example consumer: it registers a wallet_balance BotUserSort (guarded by active: fn () => settings()->get('billing.user_wallet.status')) and — depending on version — a wallet UserSection, wrapped in a class_exists(BotUserSorts::class) guard so User Wallet works whether or not User Management happens to be installed. See User Wallet for the concrete registration.