Skip to content

PR3: feat(telegram): interactive inline-keyboard menus - #142

Draft
rvalitov wants to merge 6 commits into
SamNet-dev:mainfrom
rvalitov:pr/3-menus
Draft

rvalitov wants to merge 6 commits into
SamNet-dev:mainfrom
rvalitov:pr/3-menus

Conversation

@rvalitov

@rvalitov rvalitov commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

WIP, I will provide screenshots after I finish all the tests and improvements

Fixes #138

🔢 Merge order

PR 3 of 4 — merge AFTER PR 2 (and therefore after PR 1).

All four PRs in this stack target main, because GitHub requires a PR's base
branch to exist in the target repository and these branches live in a fork.
PR 3's diff will also show PRs 1–2 until they merge — expected, and it
resolves automatically. Please merge in order: 1 → 2 → 3 → 4.

Related PR list:

  1. PR1: feat(telegram): register bot command menu with setMyCommands #140
  2. PR2: feat(telegram): keyboard primitives and update-parser rewrite #141
  3. PR3: feat(telegram): interactive inline-keyboard menus #142
  4. PR4: feat(telegram): traffic history, analytics and an informative periodic report #143

feat(telegram): interactive inline-keyboard menus

Every action currently requires typing a command with hand-supplied arguments —
/mp_remove alice, /mp_setlimit alice 5 2 10G. There are no buttons anywhere
in the codebase. This adds them.

What it looks like

Tapping 👥 Users opens a paginated list; tapping a user opens a card with that
user's live state and its own actions attached:

┌ 👥 Users (7) ─────── 1/2 ┐        ┌ 👤 alice ───────────────────┐
│ 🟢 alice   12c          │        │ 🟢 active · 12 conn · 3 IPs │
│ 🟢 bob      3c          │  tap   │ 📊 since reset: ↓4.2G ↑1.1G │
│ 🔴 carol    0c          │ ─────▶ │ 🧮 quota ▓▓▓░░░░░░░ 34%     │
│ [◀] [1/2] [▶]           │        │ ⏳ expires in 12d           │
│ [🔄 Refresh] [🏠 Menu]  │        │ [🔄 Refresh] [🔗 Link]      │
└─────────────────────────┘        │ [⏸ Disable] [♻️ Rotate]     │
                                   │ [🗑 Remove] [◀ Back]        │
                                   └─────────────────────────────┘

Tapping 📈 Traffic opens a view with 24h / 7d / 30d windows, and the ordinary
command replies (/start, /mp_status, /mp_secrets, /mp_traffic,
/mp_help) gain a button bar so the same views are reachable without typing.

Security model

callback_data is entirely attacker-controlled — a user can send any payload
they like, from any client. So every tap is re-authorised against exactly the
rules _process_cmd applies, and a button must never grant more than typing
the equivalent command would.

TG_CB_CAPS is the single source of truth for "what capability does this action
need", and both the renderer (which buttons to show) and the enforcer
(whether to act) read it, so the two cannot drift apart. A table-driven test
fails if it ever stops matching _process_cmd's gates.

The four refusals mirror _process_cmd exactly:

Situation Behaviour
Public capability Allowed before any role check, as public commands are
Role none (unauthenticated) Answered, but ignored silently — no edit, no audit entry
Recognised but underprivileged Refused loudly: toast + SECURITY line in audit.log
Unrecognised role string Fails closed to public-only and is refused loudly

That last one matters: admins.conf is a plain file an operator can hand-edit,
so anything that is not exactly superadmin or reseller must never exceed
public. a:remove/c:remove require superadmin because /mp_remove re-checks
superadmin; a:rotate only requires admin because /mp_rotate has no extra
gate.

Additional properties:

  • No chat id ever travels in callback_data. Identity comes from
    callback_query.message.chat.id, which Telegram authenticates. A chat id in
    the payload would be an attacker-controlled privilege token.
  • The payload is data, never a command. The target is re-validated against
    the secrets.conf charset and checked to still exist before anything runs,
    so a stale menu cannot act on a renamed or deleted secret.
  • Answering is structural, not a discipline. _cb_dispatch never answers and
    _process_callback always does, exactly once, on every path — including
    denial, a stale menu and an unknown namespace. Without that the client spins
    forever. A test asserts exactly one answerCallbackQuery per dispatch across
    nine different payloads.

Destructive actions

The a: namespace only renders a confirmation; only c: executes. Tapping
⏸ Disable asks, and a second tap on ✅ Yes, disable performs it — nothing
destructive is one mis-tap away. The confirmation states the consequence
("This immediately disconnects 3 active session(s)").

Statelessness

All navigation state lives in callback_data; there is no server-side session
store. Back-navigation carries the origin page forward into the detail payload
(u:s:alice:2), which costs 2 bytes and means Back returns to the page you came
from. The practical payoff: inline buttons carry their message implicitly via
callback_query.message.message_id, so a periodic report sent once with a
keyboard immediately becomes a live dashboard with no stored message id.

Testing

198 new assertions, 0 failures.

tests/test_telegram_callback_dispatch.sh  63 tests, 0 failures
tests/test_telegram_menu_render.sh       135 tests, 0 failures

The dispatcher test extracts _process_callback and _cb_dispatch from the
generated daemon (via a marker-delimited block) so it exercises what ships,
not a copy. It covers: reseller denial + audit, silent handling of none,
fail-closed handling of five unrecognised role strings, the confirmation flow,
stale labels, injected payloads, and the capability-parity table.

The render test checks every view for every role: valid markup shape, balanced
brackets, every callback_data ≤64 bytes and decodable, no row exceeding eight
buttons, no body exceeding 4096, pagination boundaries, and that a role never
sees a button it cannot use.

I also verified the guards actually bite rather than passing vacuously: weakening
a:remove to admin fails the parity test, and breaking the silent-deny for
role none fails the audit test.

A bug this caught

Pagination rendered empty beyond the first page:
local _i _start=$(( page * per )) _end=$(( _start + per )) expands _start
against the outer scope, so _end came out as per rather than
start + per. Fixed by declaring the variables separately. Pinned by the render
test.

Files

  • mtproxymax.sh — daemon heredoc: TG_CB_CAPS, dispatcher, menu views, button bars on five existing replies
  • tests/test_telegram_callback_dispatch.sh — new
  • tests/test_telegram_menu_render.sh — new
  • tests/test_telegram_reseller_rbac.sh — stubs the two new senders and exposes the menu block, since /start now routes through tg_send_to_kb

The bot never registered its commands, so Telegram's in-app "/" menu button
had nothing to show and the commands were only discoverable by reading
/mp_help. Register the list with setMyCommands so commands are tappable.

Lists are scoped to mirror the role model in _process_cmd rather than
exposing the whole admin surface to every user:

  - default scope   -> 5 public self-service commands
  - admin/superadmin chats via admins.conf -> 18 command control plane
  - root chat and superadmin admins -> those 18 plus the four commands
    gated on the superadmin role (/mp_remove, /mp_restart, /mp_update,
    /mp_lockdown)

The command tables live once, in the manager. The generated bot daemon
re-runs `mtproxymax telegram sync-commands` on boot instead of carrying its
own copy, so the menu self-heals and picks up newly added admins. Syncing is
best-effort throughout: a Telegram outage must never break setup or the poll
loop, and revoking an admin calls deleteMyCommands so a stale admin menu is
not left behind.

Also add an explicit `telegram sync-commands` subcommand for manual re-sync.
Foundation for interactive inline-keyboard menus. No user-visible change:
nothing attaches a keyboard yet, and the callback dispatcher is a stub.

Additions (all inside the generated bot daemon, which is self-contained
because the heredoc is quoted and inherits nothing from the manager):

- Bot API primitives: _tg_post_method as a single curl chokepoint, plus
  tg_send_kb/tg_send_to_kb/tg_edit/tg_edit_markup/tg_answer_cb. reply_markup
  travels as an ordinary urlencoded form field, so no Content-Type header and
  no temp file, and the token stays out of the process list.

- Message chunking (_tg_chunk_text/_tg_send_pieces). Telegram caps a message
  at 4096 units and bot messages are built by appending one line per secret,
  so a large enough fleet produced a message that failed to send outright. A
  400 from a malformed Markdown entity now retries without parse_mode,
  costing the formatting instead of the whole message.

- callback_data codec (_cb_enc/_cb_dec/_cb_label_ok). The cap is 64 bytes and
  one over-long payload makes Telegram reject the entire reply_markup, so the
  encoder refuses rather than truncating — a truncated payload would decode
  into a different, still-valid target.

Fixes two latent bugs in the getUpdates path:

- The no-python3 fallback extracted text and chat id in two independent
  grep|tail passes and paired them by position, so a batch lost every update
  but the last and could pair one update's text with another's chat id.

- callback_query updates were never parsed. The extractor read
  r.get('message',{}), which is empty for a callback, so _process_cmd ran on
  empty input, wrote the offset, and the callback was confirmed and never
  redelivered.

The replacement awk extractor is a character scanner rather than a regex
pass, since a regex cannot tell whether a brace or quote sits inside a string
literal — a command's text can contain both. Both extractors now emit
identical records, and the tests assert that byte-for-byte, because the awk
path is the only one available without python3 (notably on Alpine, which this
project supports via OpenRC).

Also makes _tg_have_python probe by executing rather than by `command -v`:
the Windows Store ships a python3.exe alias that is on PATH but fails when
run. And drops the offset write from _process_cmd into _consume_updates, so
it advances per consumed record and a parser failure mid-batch redelivers the
tail instead of losing it.
Tapping now navigates: a hub, a paginated user list, a per-user detail card
with its own actions, and a confirmation step before anything destructive.
All navigation state travels in callback_data, so there is no server-side
session store and an old message's buttons keep working.

Security model: callback_data is entirely attacker-controlled — a user can
send any payload they like — so every tap is re-authorised against the same
rules _process_cmd applies. TG_CB_CAPS is the single source of truth for
"what capability does this action need", and BOTH the renderer (which buttons
to show) and the enforcer (whether to act) read it, so the two cannot drift.
A table-driven test fails if it stops matching _process_cmd's gates.

The four refusals mirror _process_cmd exactly: public actions run before any
role check, an unauthenticated chatter is answered but ignored silently, a
recognised-but-underprivileged role is refused loudly and audited, and an
unrecognised role string fails closed to public-only (admins.conf is a plain
file an operator can hand-edit).

Answering the callback is structural rather than a discipline: _cb_dispatch
never answers and _process_callback always does, exactly once, on every path
including denial, a stale menu and an unknown namespace. Without that the
client spins forever.

Also attaches a role-filtered button bar to /start, /mp_status, /mp_secrets,
/mp_traffic and /mp_help. Inline buttons carry their message implicitly via
callback_query.message.message_id, so a reply becomes a live dashboard with
no stored message id.

Fixes a pagination bug found while testing: `local _i _start=$(( page * per ))
_end=$(( _start + per ))` expands _start against the OUTER scope, so _end came
out as per rather than start+per and every page after the first rendered
empty.

tests/test_telegram_reseller_rbac.sh gains stubs for the two new senders and
exposes the menu block, since /start now routes through tg_send_to_kb and
builds its button bar via _tg_button_bar.
Two pieces of reply plumbing, both in service of making the bot usable from
buttons and readable on a phone.

Pending input
-------------
Inline buttons cannot collect typed text, so a flow that needs a value arms a
prompt for that chat and takes the next plain message from it as the answer.
State is one line per chat in relay_stats/.tg_pending; the daemon is its only
reader and only writer, so the rewrite needs no lock.

The consumption rule is the part that has to be right, because a stale or
misfiled entry would swallow a user's next real message:

  - a slash command always escapes, and clears the prompt on the way out;
  - re-arming replaces rather than appends;
  - an expired row is dropped on read;
  - the answer runs as the sender, after the role lookup.

The first consumer is the "add user" flow, which validates the label before it
reaches the CLI and hands back a connect link built from the secret it created.

No code box
-----------
Every fenced site wrapped CLI output in ```…```, which Telegram draws as a
monospace box with a copy button — the wrong shape for a status reply.

The replacement is one line per fact. Padding is not available as an alignment
mechanism: printf "%-14s" pads with letters, and letters do not have a uniform
advance width in a proportional font, so padded columns collapse outside a
fence. Block-element glyphs are a different case and stay inline.

Two fixes fall out of reading vouchers.conf instead of the CLI's padded table:

  - /mp_voucher create showed the WRONG rows. It read the active list back and
    did `tail -n +3`, which skipped the first voucher and re-announced an
    existing code instead of the one just generated. It now snapshots the
    active count and reports only the rows past it.
  - /mp_fleet rendered one block per node rather than a padded table, so the
    columns survive without a fence.
Adds the manage card behind a user (u:m) with a picker per limit field —
quota, connections, IPs, expiry and the monthly quota-reset day — and a
template picker that applies a saved template to that secret.

The write path is the part that matters. The obvious verb for "set the quota"
is `secret setlimits <label> <conns> <ips> <quota> <expires>`, but
secret_set_limits reads "0" as UNLIMITED rather than "leave alone", so tapping
"quota: 10G" through it would silently clear the connection and IP caps too.
Every commit goes through the per-field `secret setlimit <label> <field>
<value>` form instead, and the tests pin that a quota change never calls
setlimits at all.

Values arrive through callback_data, which is attacker-controlled, and the
label can also come back out of secrets.conf, which is hand-editable, so both
are re-validated before they reach the CLI.

Two smaller things worth naming:

  - Relative expiry ("+30d") and "never" mean different CLI verbs — `secret
    extend` does the calendar arithmetic, `secret setlimit expires 0` clears
    the date — so one picker drives both.
  - A custom value the presets do not cover arms a pending prompt whose verb is
    the same one the preset commits through, so the typed value lands on
    exactly the same validated path rather than a second, laxer one.

_kb_spec drops a button whose payload would exceed 64 bytes: one oversized
payload makes Telegram reject the whole keyboard, so a missing button beats a
card that will not render. The template picker names those templates in the
body instead of letting them vanish silently.
Completes the parity goal: an operator should never have to remember a command.
The hub gains Server, Templates and Tools sections, and the views behind them
cover what /mp_digest, /mp_upstreams, /mp_fleet, /mp_voucher, /mp_update,
/mp_add, /mp_broadcast, /mp_restart, /mp_update and /mp_lockdown used to be the
only route to.

Global verbs. Rotate-all, restart, update and lockdown act on the server rather
than on one secret, so they have no label. They carry "_" as a placeholder, and
that placeholder is matched ONLY for these four — a secret genuinely called "_"
is legal and keeps its enable/disable/rotate/remove verbs. All four go through a
confirmation; the confirm tap is the only thing that performs the write, and the
test pins that the first tap runs nothing.

Templates are now editable, not just applicable. templates.conf rows are
"name|conns|ips|quota|expires|notes" and the CLI's `template save` overwrites by
name, so an edit is a read-modify-write that must carry the fields it did not
touch — notes and expiry being the easiest two to lose, since they are the last
columns. The rebuild reads and writes in one call for that reason.

/help is now rendered from `telegram commands`, which prints the same TG_CMDS_*
lists that are registered with Telegram. The old view was a hand-maintained
string that had already drifted: it advertised commands that no longer existed
and missed ones that did.

Two bugs found while wiring this up, both silent:

  - /mp_digest called load_ssl_config, load_speed_limits and
    load_cloud_backup_config, which are manager-only. Inside the daemon they are
    command-not-found under 2>/dev/null, so the digest advertised an SSL Shield
    and a Cloud Backup status it could never read. The new digest view shows
    only the settings the daemon genuinely loads.
  - _kb_can was written where _cb_can was meant in the template editor, so the
    Apply and Delete buttons never rendered at all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add interactive commands to Telegram bot

1 participant