I just spent a few days going around in circles and finally worked it out so am posting this here in case anyone finds it useful.
The aim here was to use my HA Voice PE device to store a shopping list and then, when I’m the local shops, receive a text message with the shopping list that I can check items off. I originally had an automation that sent me the todo list but whenever I stopped looking at the screen it would mean I’d have to go through the menus again to get back to the todo list. Surely there has to be a better way…
The solution uses Telegram as it is:
- Always open
- Persistent
- Easy to interact with one-handed
So I wanted this:
A single Telegram message that shows my shopping list, where I can tap items to tick them off — and the message updates in place.
After a lot of trial and error this now works reliably.
What this solution does
- Sends your HA shopping list to Telegram
- Each item has a
button - Tapping a button:
- Marks the item completed in HA
- Updates the same Telegram message
- Removes that item’s button
- When the list is empty:
- Message updates to “All done”
- Inline keyboard disappears cleanly
Requirements
- Home Assistant with the To-Do / Shopping List integration
- A Telegram bot configured in Polling mode
- At least one allowed chat ID configured for the bot
- Basic familiarity with HA automations
Broadcast Telegram bots will NOT work — callbacks require polling.
Automation 1
Send the shopping list to Telegram
This can be triggered however you like (manual, button, NFC, zone, etc.).
Below is a simple example trigger — adjust as needed.
alias: Shopping list - send to Telegram
mode: single
trigger:
- platform: state
entity_id: input_boolean.send_shopping_list
to: "on"
condition:
- condition: numeric_state
entity_id: todo.shopping_list
above: 0
action:
- service: todo.get_items
target:
entity_id: todo.shopping_list
data:
status: needs_action
response_variable: shopping
- variables:
items: "{{ shopping['todo.shopping_list']['items'] | default([]) }}"
inline_kb: >-
{% set ns = namespace(rows=[]) %}
{% for i in items[:25] %}
{% set label = '✅ ' ~ (i.summary | string | trim) %}
{% set ns.rows = ns.rows + [ label ~ ':/done_' ~ i.uid ] %}
{% endfor %}
{{ ns.rows }}
body: >-
Shopping list ({{ items | length }}):
{{ "\n" -}}
{%- for i in items %}
• {{ i.summary }}
{%- endfor %}
{{ "\n\n" -}}
Tap an item to tick it off.
- service: telegram_bot.send_message
data:
config_entry_id: <YOUR_TELEGRAM_CONFIG_ENTRY_ID>
message: "{{ body }}"
inline_keyboard: "{{ inline_kb }}"
Automation 2
Handle button taps + edit the message in place
This is the core automation that listens for Telegram callbacks.
alias: Shopping list - tick off item from Telegram (edit in place)
mode: queued
max: 10
trigger:
- platform: state
entity_id: event.shopping_list_update_event
condition:
- condition: template
value_template: >
{{ trigger.to_state.attributes.event_type == 'telegram_callback'
and (trigger.to_state.attributes.data | default('') | string).startswith('/done_') }}
action:
# Extract callback details
- variables:
cb_data: "{{ trigger.to_state.attributes.data | string }}"
callback_id: "{{ trigger.to_state.attributes.id }}"
uid: "{{ cb_data | replace('/done_', '') }}"
msg_id: "{{ trigger.to_state.attributes.message.message_id }}"
chat_id: "{{ trigger.to_state.attributes.chat_id }}"
# Mark item completed
- service: todo.update_item
target:
entity_id: todo.shopping_list
data:
item: "{{ uid }}"
status: completed
# Acknowledge callback (removes Telegram spinner)
- service: telegram_bot.answer_callback_query
data:
config_entry_id: <YOUR_TELEGRAM_CONFIG_ENTRY_ID>
callback_query_id: "{{ callback_id }}"
message: "Ticked off ✅"
# Get remaining items
- service: todo.get_items
target:
entity_id: todo.shopping_list
data:
status: needs_action
response_variable: shopping
- variables:
items: "{{ shopping['todo.shopping_list']['items'] | default([]) }}"
inline_kb: >-
{% set ns = namespace(rows=[]) %}
{% for i in items[:25] %}
{% set label = '✅ ' ~ (i.summary | string | trim) %}
{% set ns.rows = ns.rows + [ label ~ ':/done_' ~ i.uid ] %}
{% endfor %}
{{ ns.rows }}
body: >-
{% if items | length == 0 %}
✅ All done — shopping list cleared!
{% else %}
Remaining ({{ items | length }}):
{{ "\n" -}}
{%- for i in items %}
• {{ i.summary }}
{%- endfor %}
{{ "\n\n" -}}
Tap an item to tick it off.
{% endif %}
# Edit the original Telegram message
# IMPORTANT: inline_keyboard must be omitted when list is empty
- choose:
- conditions:
- condition: template
value_template: "{{ items | length > 0 }}"
sequence:
- service: telegram_bot.edit_message
data:
config_entry_id: <YOUR_TELEGRAM_CONFIG_ENTRY_ID>
chat_id: "{{ chat_id }}"
message_id: "{{ msg_id }}"
message: "{{ body }}"
inline_keyboard: "{{ inline_kb }}"
default:
- service: telegram_bot.edit_message
data:
config_entry_id: <YOUR_TELEGRAM_CONFIG_ENTRY_ID>
chat_id: "{{ chat_id }}"
message_id: "{{ msg_id }}"
message: "{{ body }}"
Gotchas & Lessons Learned (very important)
These are the things that tripped me up — hopefully they save others a lot of time.
You must use a polling Telegram bot
- Broadcast bots cannot receive callbacks
- Polling bots create an
event.<name>_update_evententity - All callbacks arrive via that entity
Allowed chat IDs are mandatory
If you see log messages like:
Unauthorized update - chat id not in allowed chats
you must re-add the Telegram integration and explicitly set allowed chat IDs.
send_message and edit_message are not equally forgiving
Some keyboard formats work for send_message but fail for edit_message.
This format works reliably for both:
"Button label:/callback_data"
Dict-style or nested list buttons caused parse errors or weird labels.
You cannot send an empty inline keyboard
When the list is empty:
- Do not send
inline_keyboard - Not
[] - Not an empty template
- Omit the field entirely
Using a choose: block is the only reliable way — Jinja omit was inconsistent.
The automation will trigger more than once — that’s OK
You may see log entries like:
If template renders false
This is expected — Telegram sends multiple update types, and the condition filters what you care about.
Final thoughts
This ended up being far trickier than expected but the result is genuinely useful in daily life and hopefully this helps.