Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #137
Related PR list:
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_queryupdates are permanently swallowedThe
getUpdateshandler readsr.get('message', {}). Acallback_queryupdatehas no
messagekey at that position, so it yields an empty text and an emptychat id — but the loop guard only checks the update id, so
_process_cmdrunson 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 thechat id in two independent
grep | tail -1passes and paired them byposition. Consequences:
"chat"\s*:\s*\{requiredchatand{to be adjacent, so a reorderedobject 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 paththere.
The rewrite
Both extractors now emit the same tab-separated records:
kindismsgorcb. A record is emitted for every element ofresult,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 canlegitimately 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_pythonprobes by executingpython3rather thancommand -v.The Windows Store ships a
python3.exealias that sits on PATH and fails themoment it runs;
command -vbelieves it._process_cmdinto the consumer, so it advancesper consumed record. A parser failure mid-batch now redelivers the tail
instead of losing it.
in the main shell and do not lose the variables they set.
\ntoos.linesep, soon Windows the records came out CRLF and the trailing
\rlanded inside everycommand payload.
Message primitives
Added alongside
tg_send/tg_send_to, which are deliberately not modified —they have ~40 call sites and
tg_sendalso fireswebhook_send._tg_post_method— one curl chokepoint. Fields travel as urlencoded formvalues, so
reply_markupis just a JSON string in one of them: noContent-Typeheader, 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_pieces— message 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.
parse_mode. A malformed legacyMarkdown entity currently loses the whole message; this makes the failure
cosmetic instead of total.
tg_edittreats"message is not modified"as success — Telegram 400s an editwhose text and markup are unchanged, which happens on every tap of the page
the user is already on.
callback_datacodecGrammar
<ns>[:<action>[:<target>[:<page>]]], all ASCII._cb_encrefusesrather than truncating when a payload would exceed Telegram's 64-byte cap —
one over-long payload makes Telegram reject the entire
reply_markup, losingthe whole message, and a truncated payload would decode into a different,
still-valid target. Targets are validated against the
secrets.confcharset, soa
:in a label cannot shift the positional fields._cb_decsets globals rather than printing, so rendering a 30-button menu doesnot fork 30 subshells.
Testing
94 new assertions, 0 failures, plus the existing 62-assertion RBAC suite
passing unchanged.
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
messageupdates and refuses to advance theoffset past a
callback_query— degraded, but never wrong.Files
mtproxymax.sh— daemon heredoc only (_esconward, plusprocess_commands)tests/test_telegram_callback_data.sh— newtests/test_telegram_message_primitives.sh— newtests/test_telegram_update_parser.sh— new