Batch proposals shipped with a proper review UI on the web. Inline cards, per-item approve/skip/edit buttons, the full treatment. Telegram got the short end: a numbered list and two buttons - "Confirm All" or "Cancel." Binary choice. If one out of five reminders had the wrong time, your options were confirm everything or cancel everything and start over.
This is the same gap that existed with streaming before yesterday's edit-in-place work. The web had a polished experience, Telegram had a stripped-down fallback. Same pattern, same fix: bring the Telegram surface up to parity.
Working within 64 bytes
Telegram inline keyboards have a hard limit on callback data: 64 bytes per button. That's the string that comes back when someone taps. You can't stuff a JSON payload in there. The format needed to encode the operation, the proposal ID, and the item ID in something compact.
Colons: bp_approve:42:17. Action, proposal, item. Well within the limit even with large IDs. bp_skip:42:17 for skipping. The existing bp_confirm:42 and bp_cancel:42 stay unchanged. No migration needed for the bulk actions.
How the keyboard evolves
The initial message shows all items with an hourglass emoji next to each one. Below the list, two buttons per item (approve and skip), packed two items per row so a 4-item proposal gets a tight grid:
[✓ 1] [✗ 1] [✓ 2] [✗ 2]
[✓ 3] [✗ 3] [✓ 4] [✗ 4]
[Confirm All] [Cancel]
Tap approve on item 2 and three things happen in one round-trip: the item's status flips to approved in the database, the message text updates with a checkmark replacing the hourglass, and the keyboard rebuilds without buttons for that item. It's the same editMessageText call from the streaming work, but now it also carries a reply_markup parameter to swap the keyboard at the same time. One API call, both the text and the buttons update.
As you work through items, the keyboard shrinks. Once everything has been actioned, only Confirm All and Cancel remain.
Sharing the formatter
The initial proposal send (in TelegramCommandService) and the callback handler (in TelegramWebhookController) both need to build the same message text and keyboard. Rather than duplicate the formatting logic, a TelegramProposalFormatter centralises it. Status emoji mapping, item line formatting, keyboard layout - one class, two consumers. Change the emoji for "skipped" in one place and both paths pick it up.
The formatter also builds the post-confirmation summary. After execution, the message transforms from a review card into a receipt: each item shows its final status (created, skipped, or failed) and a result line at the bottom. The proposal message becomes a permanent record of what happened.
Catching stale taps
Proposals expire after 24 hours. If someone leaves a proposal sitting in Telegram and comes back the next day, tapping approve doesn't silently fail. The callback handler checks expiry first, flips the proposal to expired, and answers with "Proposal has expired." Same check for proposals that were already confirmed or cancelled from the web side. Telegram's callback acknowledgement system (the little toast that appears when you tap a button) is perfect for these one-line status messages.
Items that don't belong to the proposal - malformed callback data or someone replaying old requests - get a clean "Item not found" response. The proposal's items are loaded eagerly on every callback, so validation is an in-memory lookup.
What it feels like now
Before: "Remember these four things" on Telegram gave you a numbered list and a binary choice. After: you see the list with status indicators, tap to approve items you want, skip the ones you don't, watch the message update in real time, then confirm when you're satisfied. Same proposal, same execution pipeline, same expiry rules. Just proper controls on the Telegram surface instead of the all-or-nothing fallback.