Skip to content

Answer callback queries, optionally hide inline buttons and link previews - #92

Closed
mysubcult wants to merge 1 commit into
LiveHelperChat:masterfrom
mysubcult:feat/callback-query-hide-buttons-link-preview
Closed

mysubcult wants to merge 1 commit into
LiveHelperChat:masterfrom
mysubcult:feat/callback-query-hide-buttons-link-preview

Conversation

@mysubcult

Copy link
Copy Markdown
Contributor

Follow-up to #91 — done via bot builder only, as suggested there. No PHP listeners.

Problem

When a visitor taps an inline button, Telegram shows a spinner on it until the bot calls answerCallbackQuery. Nothing answered it, so the spinner hung and visitors tapped the same button again and again, re-triggering bot flows. The buttons also stayed clickable forever, unlike in the website widget.

Changes

  • Answer callback query trigger in the TelegramIntegration bot, executed by new chat.webhook_incoming_chat_continue / chat.webhook_incoming_chat_started webhooks (conditions: scope telegram, callback_query.id not empty).
    • answerCallbackQuery — stops the spinner. Note the body uses {{args.data.callback_query.id}} without quotes: the value is JSON-encoded by the REST API action, wrapping it in quotes produces invalid JSON.
    • editMessageReplyMarkup with an empty keyboard — only when incoming webhook attribute hide_buttons_on_click is true (default).
  • disable_link_preview incoming webhook attribute (default false): when set, sendMessage gets link_preview_options.is_disabled. If the attribute is missing nothing is added to the body.
  • Activator keeps attribute values edited by the administrator (access_token, the new flags…) on update instead of resetting them to the template, and (re)creates the callback webhooks.
  • README: documented both attributes.

Testing

Rendered all REST methods through erLhcoreClassGenericBotActionRestapi::makeRequest against a local echo server with the attributes set to true, false and missing, and checked the resulting request bodies. Running in production on a Telegram bot.

…iews

- Add `Answer callback query` trigger to the TelegramIntegration bot. It is
  executed by the chat.webhook_incoming_chat_continue/_started webhooks and
  calls answerCallbackQuery, so Telegram stops the spinner on the clicked
  button. Done via bot builder only, no PHP listeners.
- Same trigger calls editMessageReplyMarkup with an empty keyboard when the
  incoming webhook attribute `hide_buttons_on_click` is `true` (default), so
  visitors can't click the same buttons again, like in the website widget.
- New incoming webhook attribute `disable_link_preview` (default `false`)
  adds `link_preview_options.is_disabled` to sendMessage.
- Activator keeps administrator-edited attribute values on update and
  (re)creates the callback webhooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@remdex

remdex commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

If button is clicked bot trigger is executed this trigger is responsible to send request back to telegram. Not additional webhooks...

@mysubcult

Copy link
Copy Markdown
Contributor Author

Thanks! I'd be glad to do it inside the bot trigger, but as far as I can see the trigger executed by the button click doesn't get callback_query.id:

  • processMessage runs the button trigger with erLhcoreClassGenericBotWorkflow::processTrigger($chat, $event->trigger) — the webhook payload isn't passed to it;
  • message meta only has iwh_msg_id (message.message_id), which a callback query doesn't have;
  • incoming_chat.payload is saved only for the first message of the chat.

Without the id answerCallbackQuery can't be called, and Telegram keeps the loading spinner on the button until it times out — visitors tap the button again and the trigger runs twice. The id is available only in chat.webhook_incoming_chat_continue / chat.webhook_incoming_chat_started (args.data), that's why I used them.

Is there a way to read the incoming payload from a trigger that I'm missing? If not, would you prefer some minimal core change instead, e.g. passing the webhook payload to the button trigger args, so the Answer callback query REST call can be added right into the trigger as you suggest?

The link preview option didn't need any of this, so I sent it separately as a template-only PR: #93.

@remdex

remdex commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Give sample how payload looks like and what attribute from it you want to reach?

@mysubcult

Copy link
Copy Markdown
Contributor Author

Sure. This is what Telegram sends to the incoming webhook when a visitor taps an inline button (ids anonymized):

{
  "update_id": 912345678,
  "callback_query": {
    "id": "4122368201564890123",
    "from": { "id": 512345678, "is_bot": false, "first_name": "John", "language_code": "en" },
    "message": {
      "message_id": 1842,
      "from": { "id": 7012345678, "is_bot": true, "first_name": "SmartDiag", "username": "smartdiag_bot" },
      "chat": { "id": 512345678, "first_name": "John", "type": "private" },
      "date": 1760280000,
      "text": "Choose an option",
      "reply_markup": {
        "inline_keyboard": [[ { "text": "Yes", "callback_data": "trigger__2021__a1b2c3__98765" } ]]
      }
    },
    "chat_instance": "-3456789012345678901",
    "data": "trigger__2021__a1b2c3__98765"
  }
}

callback_query.data is the button payload LHC put into callback_data — the incoming webhook already uses it (msg_btn_payload_1 = callback_query.data) to find and run the trigger.

Attributes I'd like to reach from that trigger:

Attribute Used for
callback_query.id required — answerCallbackQuery ({"callback_query_id": ...}) to stop the loading spinner on the button
callback_query.message.message_id optional — editMessageReplyMarkup to remove the keyboard from the message that was clicked

chat_id is already available (chat.incoming_chat.chat_external_id). message_id isn't: the webhook takes it from message.message_id, which a callback update doesn't have, so iwh_msg_id is empty for clicks.

For example, if the button trigger got the incoming payload in its args, the REST API call inside the trigger could be just:

answerCallbackQuery
{"callback_query_id": {{args.webhook_payload.callback_query.id}}}

Right now the click goes processMessage → sendBotResponse → processTriggerClick / processButtonClick (or processTrigger($chat, $event->trigger) for plain payloads), and none of them receive $payloadMessage, so the trigger can't see it. The name webhook_payload is just an example — whatever you prefer.

Max has the same need (callback.callback_id for POST /answers), so the same args would cover lhcmax as well.

@remdex

remdex commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

You can just use this attribute which holds data of the last payload. In our scenario it's a button click :)

{{args.chat.incoming_chat.payload_array.callback_query.id}}

@remdex

remdex commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

With this commit you would see.
LiveHelperChat/livehelperchat@70baf7c

All args :) In audit list this item is presented.
image

@mysubcult

Copy link
Copy Markdown
Contributor Author

Thanks, that would be the cleanest way! But payload_array holds the payload of the first message of the chat, not the last one. For an existing chat the webhook only fills it when it's empty:

https://github.com/LiveHelperChat/livehelperchat/blob/70baf7cf46185c44d3053b2cd7cf2ccb70b4a40c/lhc_web/lib/core/lhchat/lhchatwebhookincomming.php#L1545-L1547

if (empty($eChat->payload)) {
    $eChat->payload = json_encode($payloadAll);
}

So in a normal flow (visitor writes /start or any text first, then clicks a button) {{args.chat.incoming_chat.payload_array.callback_query.id}} is empty — payload_array still contains the first message update. I checked it on our production data: payloads of Telegram chats with button clicks are all the initial message updates. I guess in your test the chat was started by the click itself, so the first payload happened to be the callback_query.

Could the payload be refreshed on every incoming message instead, i.e. just

$eChat->payload = json_encode($payloadAll);

(or behind an incoming webhook option, if someone relies on the first payload)? Then the button trigger can do everything itself, no extra webhooks:

answerCallbackQuery
{"callback_query_id": {{args.chat.incoming_chat.payload_array.callback_query.id}}}

editMessageReplyMarkup
{"chat_id": {{args.chat.incoming_chat.chat_external_id}}, "message_id": {{args.chat.incoming_chat.payload_array.callback_query.message.message_id}}, "reply_markup": {"inline_keyboard": []}}

and I'll update this PR to put these calls into the bot trigger as you suggested. Same for lhcmax (callback.callback_id).

@remdex

remdex commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

I'll see tomorrow.

@remdex

remdex commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Updated master branch to store latest payload always.

@mysubcult
mysubcult deleted the feat/callback-query-hide-buttons-link-preview branch October 26, 2026 10:20
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.

2 participants