Skip to content

PR2: feat(telegram): keyboard primitives and update-parser rewrite - #141

Draft
rvalitov wants to merge 2 commits into
SamNet-dev:mainfrom
rvalitov:pr/2-foundations
Draft

rvalitov wants to merge 2 commits into
SamNet-dev:mainfrom
rvalitov:pr/2-foundations

Conversation

@rvalitov

@rvalitov rvalitov commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #137

🔢 Merge order

PR 2 of 4 — merge 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 2's diff will also show PR 1's commits until PR 1 merges — that is
expected and 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): keyboard primitives and update-parser rewrite

No user-visible change. This is the foundation the interactive menu PR builds on,
and it fixes two bugs in the incoming-update path that are live today.

Bug 1 — callback_query updates are permanently swallowed

The getUpdates handler reads r.get('message', {}). A callback_query update
has no message key at that position, so it yields an empty text and an empty
chat id — but the loop guard only checks the update id, so _process_cmd runs
on empty input, writes the offset, and the callback is confirmed and never
redelivered.

This is invisible today because nothing sends inline keyboards yet. It becomes a
hard blocker the moment anything does.

Bug 2 — the no-python3 fallback drops updates

On a host without a working python3, the fallback extracted the text and the
chat id in two independent grep | tail -1 passes and paired them by
position. Consequences:

  • a batch of several updates lost every one but the last;
  • update A's text could be paired with update B's chat id;
  • "chat"\s*:\s*\{ required chat and { to be adjacent, so a reordered
    object silently failed to match.

This matters for reachability: Alpine is supported (OpenRC support landed in
#130) and typically ships without python3, so the fallback is the only path
there.

The rewrite

Both extractors now emit the same tab-separated records:

<update_id>\t<kind>\t<chat_id>\t<message_id>\t<callback_id>\t<payload>

kind is msg or cb. A record is emitted for every element of result,
including ones carrying no message, because the consumer advances the offset per
record — skipping one would make Telegram redeliver it forever.

The awk extractor is a character scanner, not a regex pass. A regex cannot
tell whether a } or " sits inside a string literal, and a command's text can
legitimately contain both. The scanner tracks string and escape state and
object/array depth, so embedded JSON in a text field is inert. This is pinned by
a fixture whose message text is tricky "chat":{"id":999} and } and \ backslash.

Other changes:

  • _tg_have_python probes by executing python3 rather than command -v.
    The Windows Store ships a python3.exe alias that sits on PATH and fails the
    moment it runs; command -v believes it.
  • The offset write moves out of _process_cmd into the consumer, so it advances
    per consumed record. A parser failure mid-batch now redelivers the tail
    instead of losing it.
  • The consumer uses process substitution rather than a pipe, so dispatchers run
    in the main shell and do not lose the variables they set.
  • Python output pins LF. In text mode Python translates \n to os.linesep, so
    on Windows the records came out CRLF and the trailing \r landed inside every
    command payload.

Message primitives

Added alongside tg_send/tg_send_to, which are deliberately not modified —
they have ~40 call sites and tg_send also fires webhook_send.

  • _tg_post_method — one curl chokepoint. Fields travel as urlencoded form
    values, so reply_markup is just a JSON string in one of them: no
    Content-Type header, no temp file, token stays out of the process list.
  • tg_send_kb / tg_send_to_kb / tg_edit / tg_edit_markup / tg_answer_cb
  • _tg_chunk_text / _tg_send_piecesmessage chunking, which did not exist.
    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 fenced block that straddles a split is closed and reopened so
    monospace alignment survives.
  • A 400 from Telegram retries once without parse_mode. A malformed legacy
    Markdown entity currently loses the whole message; this makes the failure
    cosmetic instead of total.
  • tg_edit treats "message is not modified" as success — Telegram 400s an edit
    whose text and markup are unchanged, which happens on every tap of the page
    the user is already on.

callback_data codec

Grammar <ns>[:<action>[:<target>[:<page>]]], all ASCII. _cb_enc refuses
rather than truncating when a payload would exceed Telegram's 64-byte cap —
one over-long payload makes Telegram reject the entire reply_markup, losing
the whole message, and a truncated payload would decode into a different,
still-valid target. Targets are validated against the secrets.conf charset, so
a : in a label cannot shift the positional fields.

_cb_dec sets globals rather than printing, so rendering a 30-button menu does
not fork 30 subshells.

Testing

94 new assertions, 0 failures, plus the existing 62-assertion RBAC suite
passing unchanged.

tests/test_telegram_callback_data.sh     58 tests, 0 failures
tests/test_telegram_message_primitives.sh 16 tests, 0 failures
tests/test_telegram_update_parser.sh      20 tests, 0 failures
tests/test_telegram_reseller_rbac.sh      62 tests, 0 failures

The parser test asserts the two extractors produce byte-identical output on
every fixture. That is the contract that keeps the fallback honest, and it is
what caught the CRLF divergence above.

Risk

The awk tokenizer is the only genuinely novel algorithm here and its failure
mode is silent. It is mitigated by the byte-identical assertion plus
deliberately hostile fixtures. If it ever proves too fiddly, the safe retreat is
a smaller awk that handles only message updates and refuses to advance the
offset past a callback_query — degraded, but never wrong.

Files

  • mtproxymax.sh — daemon heredoc only (_esc onward, plus process_commands)
  • tests/test_telegram_callback_data.sh — new
  • tests/test_telegram_message_primitives.sh — new
  • tests/test_telegram_update_parser.sh — new

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.
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.

getUpdates parser loses updates and silently swallows callback queries

1 participant