I have a Telegram bot that sends photos to a single user in a 1:1 private DM. I want to detect when the user adds a heart reaction (❤️) to one of those photos so I can trigger an action — save it, log an analytics event, etc.
The Bot API has an Update type for this called message_reaction. Per the Bot API spec:
Optional. A reaction to a message was changed by a user. **The bot must be an administrator in the chat* and must explicitly specify
"message_reaction"in the list ofallowed_updatesto receive these updates. The update isn't received for reactions set by bots.*
Same constraint repeated in grammY's reactions guide and python-telegram-bot's MessageReactionHandler docs.
The catch: a bot cannot be administrator of a 1:1 private DM — admin status is a group/channel-only concept. So in practice no message_reaction updates ever arrive for bot-sent messages in private chats.
What I've tried (none work):
Subscribed
message_reactionandmessage_reaction_countinallowed_updatesviagetUpdates. Confirmed viagetWebhookInfothat the bot's filter includes both. Listener ran for 10 minutes, reacted to a bot photo from my own account → zero events.Tried Bot API 7.x Business Bot mode (added the bot to my Business → Chatbots settings). The reaction update still doesn't arrive —
MessageReactionUpdatedhas nobusiness_connection_idfield, and Business mode doesn't change the admin requirement for reactions.Tried calling
client.add_event_handlerwith Telethon'sevents.Rawfilter onUpdateMessageReactions, using a user-account MTProto session (not the bot's). Listener ran 90 seconds while I reacted → zero events. Telegram's MTProto server simply doesn't push these updates over the user's update stream either.
What does work (from a probe — answer below): asking the user-account MTProto session for client.get_messages(bot, limit=N) and reading each Message.reactions field returns the current reaction state, including hearts on bot messages. So the data IS reachable from the user side, but only via polling, not push.
After confirming the limitation is structural and trying every push-mode approach I could find, MTProto user-account polling is the only thing that works. ~150 lines of Telethon, runnable as a long-lived service. Reference implementation in this repo. Quick summary below.
Why push-mode fails
- Bot API: blocked by the "must be administrator" rule — bots can't be admins of 1:1 DMs.
-
Telethon
events.RawforUpdateMessageReactions: Telegram's MTProto server doesn't forward these updates over the user account's update stream for reactions on bot messages in private DMs. This is empirical — I haven't found a spec quote stating the rule, just evidence that the events don't arrive. -
Business Bot mode:
MessageReactionUpdatedhas nobusiness_connection_idfield, so Business connections don't change reaction delivery rules.
Why polling works
The reaction state IS attached to each Message object when you ask for it directly. So:
msgs = await client.get_messages(bot_entity, limit=5)
for m in msgs:
if m.reactions and m.reactions.results:
for r in m.reactions.results:
print(r.reaction, r.count)
This returns the current state every call. Poll every N seconds, dedupe message_ids you've already fired for, and you have working heart detection.
Working implementation
#!/usr/bin/env python3
"""Heart-react MTProto poller for 1:1 Telegram DMs."""
import asyncio
import json
import os
import sys
import time
from datetime import datetime
from telethon import TelegramClient
from telethon.tl.types import ReactionEmoji
# Configure these for your install.
SESSION_PATH = "/path/to/your/telethon-user" # without .session suffix
API_ID = int(os.environ["TELEGRAM_API_ID"])
API_HASH = os.environ["TELEGRAM_API_HASH"]
BOT_USERNAME = "@your_bot_username"
POLL_INTERVAL = 8
MESSAGES_LOOKBACK = 100 # Telegram's per-call cap on GetHistory
SEEN_FILE = os.path.expanduser("~/.heart_react_seen.json")
SEEN_CAP = 100
HEART_EMOJIS = {
"❤️", "🩷", "🧡", "💛", "💚", "💙", "🩵",
"💜", "🤎", "🖤",
"🩶", "🤍", "💔", "❤️🔥", "❤️🩹", "❣️",
"💕", "💞", "💓",
"💗", "💖","💘", "💝", "💟", "💌",
}
def _seen_load():
try:
with open(SEEN_FILE) as f:
return set(json.load(f))
except (FileNotFoundError, json.JSONDecodeError):
return set()
def _seen_save(seen):
with open(SEEN_FILE, "w") as f:
json.dump(sorted(seen)[-SEEN_CAP:], f)
def _is_heart(reaction_obj):
return (isinstance(reaction_obj, ReactionEmoji)
and reaction_obj.emoticon in HEART_EMOJIS)
def on_heart(message, message_epoch):
"""Replace this with your own action."""
print(f"[heart] msg_id={message.id} epoch={message_epoch}", flush=True)
async def main():
client = TelegramClient(SESSION_PATH, API_ID, API_HASH)
await client.connect()
if not await client.is_user_authorized():
sys.exit("session not authorized — run a one-time login first")
bot_entity = await client.get_entity(BOT_USERNAME)
seen = _seen_load()
print(f"armed; loaded {len(seen)} prior msg_ids", flush=True)
while True:
try:
msgs = await client.get_messages(bot_entity,
limit=MESSAGES_LOOKBACK)
except Exception as e:
print(f"poll error: {e}", flush=True)
await asyncio.sleep(POLL_INTERVAL)
continue
new = 0
today_local = datetime.now().astimezone().date()
for m in msgs:
if getattr(m, "sender_id", None) != bot_entity.id:
continue
# Optional: only consider today's messages so the dedupe
# set rolls over each midnight automatically.
try:
if m.date.astimezone().date() != today_local:
continue
except Exception:
pass
rx = getattr(m, "reactions", None)
if rx is None:
continue
results = getattr(rx, "results", []) or []
has_heart = any(_is_heart(getattr(r, "reaction", None))
for r in results)
if not has_heart:
seen.discard(m.id) # heart removed → un-dedupe
continue
if m.id in seen:
continue
seen.add(m.id)
new += 1
try:
msg_epoch = int(m.date.timestamp())
except Exception:
msg_epoch = int(time.time())
on_heart(m, msg_epoch)
if new:
_seen_save(seen)
await asyncio.sleep(POLL_INTERVAL)
if __name__ == "__main__":
asyncio.run(main())
One-time login
You need an authenticated MTProto session for the user account first. Get an api_id + api_hash from https://my.telegram.org/apps (sign in as the user, not the bot), then:
from telethon.sync import TelegramClient
client = TelegramClient("/path/to/your/telethon-user", API_ID, API_HASH)
client.start() # prompts for phone, SMS code, 2FA password
client.disconnect()
After that, the .session file is reusable.
Run it as a service
I run mine as a systemd user service:
[Unit]
Description=Heart-react MTProto poller
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /path/to/heart_react_poller.py
EnvironmentFile=/path/to/.env
Restart=on-failure
RestartSec=10s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
Key gotchas I hit
Telethon
events.Rawdoes NOT receiveUpdateMessageReactionsfor reactions on bot messages in 1:1 DMs. Don't waste time on push-mode listeners. Polling is the only working approach.Bot-side
message_id≠ user-sidemessage_id. In MTProto, each peer sees its own message numbering. Themsg_idyour bot got fromsendPhotois NOT the samem.idyour user-account session sees. If you need to correlate, do it through external state (timestamp, caption text, your own log) — not by passing message_ids.MESSAGES_LOOKBACKmatters. Started at 5 and missed hearts on photos earlier in a 12-photo batch. Bumped to 100 (Telegram's per-call max formessages.GetHistory) and added a "today only" filter so the dedupe set rolls over naturally each midnight.Polling cost is negligible.
messages.GetHistoryis one of the cheapest MTProto calls. ~10K calls/day at 8-second cadence is well within rate limits — no flood-wait risk.8 seconds is the cadence sweet spot. Lower adds load without UX gain; higher feels laggy.
What this does NOT solve
- Reactions on other users' messages in groups/supergroups — bot can be admin there, Bot API works the standard way.
-
Bot-set reactions (the bot reacting to user messages) — the Bot API has
setMessageReactionfor this; works in 1:1 DMs without admin status.
Full reference repo with login script, .env.example, systemd unit, and a longer write-up of the gotchas: github.com/clarkeyyc/telegram-1to1-reaction-listener.
Top comments (0)