Detect heart reactions (❤️) on bot-sent messages in 1:1 Telegram DMs

Detect heart reactions (❤️) on bot-sent messages in 1:1 Telegram DMs

Clarke Schroeder
Code

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 of allowed_updates to 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):

  1. Subscribed message_reaction and message_reaction_count in allowed_updates via getUpdates. Confirmed via getWebhookInfo that the bot's filter includes both. Listener ran for 10 minutes, reacted to a bot photo from my own account → zero events.

  2. Tried Bot API 7.x Business Bot mode (added the bot to my Business → Chatbots settings). The reaction update still doesn't arrive — MessageReactionUpdated has no business_connection_id field, and Business mode doesn't change the admin requirement for reactions.

  3. Tried calling client.add_event_handler with Telethon's events.Raw filter on UpdateMessageReactions, 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.Raw for UpdateMessageReactions: 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: MessageReactionUpdated has no business_connection_id field, 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

  1. Telethon events.Raw does NOT receive UpdateMessageReactions for reactions on bot messages in 1:1 DMs. Don't waste time on push-mode listeners. Polling is the only working approach.

  2. Bot-side message_id ≠ user-side message_id. In MTProto, each peer sees its own message numbering. The msg_id your bot got from sendPhoto is NOT the same m.id your 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.

  3. MESSAGES_LOOKBACK matters. Started at 5 and missed hearts on photos earlier in a 12-photo batch. Bumped to 100 (Telegram's per-call max for messages.GetHistory) and added a "today only" filter so the dedupe set rolls over naturally each midnight.

  4. Polling cost is negligible. messages.GetHistory is one of the cheapest MTProto calls. ~10K calls/day at 8-second cadence is well within rate limits — no flood-wait risk.

  5. 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 setMessageReaction for 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.