PROJECT 12 · CLOUD / MQTT

ESP32 Telegram Bot

Chat with your board: read the sensor and switch the light from Telegram, anywhere.

Builds on: Project 11's cloud connection, swapping a dashboard for a chat app as the interface.

⏱ ~35 min 📊 Intermediate 🔖 Prerequisite: Project 9 & 11
⬇ Download the sketch Save Project_12_ESP32_Telegram_Bot.ino. Needs UniversalTelegramBot, ArduinoJson (v6+), and the DHT libraries, plus your Wi-Fi and a bot token.
🎲 Try it without hardware 📄 diagram.json Open the Wokwi simulator, then open or download diagram.json above and paste its wiring in to load the circuit.

🎯 Objective

By the end of this project, you will:

  • Use an existing chat app as your device's user interface: no page to design, nothing to install on the phone.
  • Understand why polling an outside service reaches your board on networks where hosting a web server never would.
  • Parse a text command and dispatch to the right action.
  • Send a message the other way, unprompted, when a sensor event happens: a push notification from your own hardware.
  • Meet HTTPS on a microcontroller, and see what a root certificate is for.
  • Restrict who can control the board by checking the sender's chat id before acting on a command, the same authentication idea behind API keys and passwords.
  • Compare three control models: local web server (Projects 5 to 9), cloud dashboard (Project 11), and chat (this one).

1How it works

Telegram runs a bot API over HTTPS. The ESP32 asks Telegram "any new messages?" once a second and acts on them, and it can also send you a message when something happens (motion).

You (Telegram app) <-- HTTPS --> Telegram servers <-- HTTPS --> ESP32

Because it is just outbound HTTPS, this reaches your phone even on networks where a self-hosted web server would not.

CommandWhat it does
/statustemperature, humidity, light state
/light_on · /light_offswitch the LED or relay
/helplist the commands

2Create your bot

  1. In Telegram, chat with @BotFather and send /newbot.
  2. Pick a name and a username ending in bot. Copy the HTTP API token.
  3. Open @userinfobot to get your numeric chat id. Setting it locks the bot to just you (Step 3 below) and lets it push motion alerts; leave it blank and the bot answers anyone who finds its username.

3Install the required libraries

From the Arduino Library Manager (setup: Foundation 3):

  • UniversalTelegramBot (by Brian Lough)
  • ArduinoJson (by Benoit Blanchon, version 6 or newer)
  • DHT sensor library (Adafruit) and Adafruit Unified Sensor

4Parts and wiring

QtyPartNotes
1ESP32 · breadboard · jumper wiresas always
1DHT11S to GPIO 4, + to 3.3 V, - to GND
1LED + 220 Ohm or relayto GPIO 26
1PIR (optional)VCC to 5 V, OUT to GPIO 27, GND to GND
Same breadboard as Project 11, with the PIR from Project 4 added if you want motion alerts.

5Set your details and upload

Edit the marked block: WIFI_SSID, WIFI_PASS, BOT_TOKEN, and CHAT_ID (leave it "" to accept commands from anyone and skip motion pushes, or set it to restrict the bot to just you). Upload, open Serial Monitor at 115200, then send /start to your bot in Telegram.

==============================================
 Project 12: ESP32 Telegram Bot
==============================================
Connecting to Wi-Fi: MyNetwork
....
Wi-Fi connected!
Bot ready. Open Telegram and send /start to your bot.

6Code Walkthrough: Understanding the Sketch

More moving parts than anything before it, but each one is something you have already met.

Step 1: A secure client, and a bot that rides on it

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <UniversalTelegramBot.h>
#include <ArduinoJson.h>

WiFiClientSecure secured_client;
UniversalTelegramBot bot(BOT_TOKEN, secured_client);

Two objects, stacked. WiFiClientSecure is a TCP connection with TLS on top: the https:// part. UniversalTelegramBot sits above it and knows Telegram's API, turning sendMessage() into the right HTTPS request. ArduinoJson is underneath both, decoding the replies, which is why the library needs it even though your code never mentions it.

The token identifies your bot to Telegram. Anyone with it can send messages as your bot, so it belongs in the edit block and nowhere public.

Step 2: Trusting Telegram, in setup()

secured_client.setCACert(TELEGRAM_CERTIFICATE_ROOT);

HTTPS is only useful if the client can tell the real server from an impostor, and that check works by trusting a root certificate. Your laptop ships with hundreds. An ESP32 ships with none, so the library bundles the one Telegram's certificate chains up to and this line installs it. Skip it and every request fails to connect, even with a perfect token.

Step 3: Authorizing who can talk to your bot

String chat_id = bot.messages[i].chat_id;
...
if (String(CHAT_ID).length() > 0 && chat_id != String(CHAT_ID)) {
  bot.sendMessage(chat_id, "Unauthorized user", "");
  continue;
}

The token in Step 1 stops outsiders from pretending to be your bot, but says nothing about who is allowed to talk to it: a bot's username is public, so anyone who finds it can open a chat and send /light_on. This check closes that gap with the same idea behind a login screen, comparing an identity (the sender's chat_id) against an allow list of one (CHAT_ID) before any command runs. continue skips straight to the next queued message, so a stranger gets one polite rejection and nothing else executes, just like the real bot below.

Telegram chat showing the bot rejecting a stranger's /start with "Unauthorized user"
Source: Random Nerd Tutorials, "Telegram: Control ESP32/ESP8266 Outputs", the tutorial this pattern is adapted from.

String(CHAT_ID).length() > 0 is the escape hatch: an empty placeholder short-circuits the whole check, so the sketch still runs for a first test before you have your id, just with an open bot.

Step 4: handleNewMessages() reads the text and dispatches

for (int i = 0; i < numNewMessages; i++) {
  String chat_id = bot.messages[i].chat_id;
  String text    = bot.messages[i].text;
  String from    = bot.messages[i].from_name;

  if (text == "/light_on") {
    digitalWrite(LED_PIN, HIGH);
    bot.sendMessage(chat_id, "Light is ON", "");
  }
  ...
  else {
    bot.sendMessage(chat_id, "Unknown command. Send /help.", "");
  }
}

Several messages can be waiting, so the function takes a count and loops, running Step 3's check on each one before deciding anything else. Each message that passes carries who sent it (from_name), what they typed (text), and which conversation it came from (chat_id). Replying to chat_id rather than a hardcoded value still matters with a single authorized sender: set CHAT_ID to a group's id instead of a person's and this is what sends the reply back to that group.

The chain of if/else if is a command dispatcher, the same job the URL parsing did in Project 5. The final else matters: a bot that ignores what it does not understand feels broken, while one that says "send /help" teaches its own interface.

/status reads the sensor at the moment you ask, with the same isnan() guard as Project 9, and builds the reply with String(t, 1) so 24.63 prints as 24.6.

Step 5: loop() pushes on motion, then polls for commands

bool motion = digitalRead(PIR_PIN);
if (motion && !lastMotion && String(CHAT_ID).length() > 0) {
  bot.sendMessage(CHAT_ID, "Motion detected!", "");
  Serial.println("[PIR] motion -> alert sent");
}
lastMotion = motion;

if (millis() - lastBotCheck > BOT_CHECK_INTERVAL) {
  int numNewMessages = bot.getUpdates(bot.last_message_received + 1);
  while (numNewMessages) {
    handleNewMessages(numNewMessages);
    numNewMessages = bot.getUpdates(bot.last_message_received + 1);
  }
  lastBotCheck = millis();
}

motion && !lastMotion is Project 4's edge detection, doing real work here: without it, standing in front of the sensor would send a message every few milliseconds until Telegram rate-limited the bot.

The second half is polling. getUpdates(last_message_received + 1) asks "anything newer than the last one I saw?", and the + 1 is what stops the board replying to the same message forever. The inner while drains the queue, since a burst may not arrive in one response.

Note who does the asking: the ESP32 always opens the connection outwards, which is exactly why this works behind a router that would never let an incoming request through.

Key concepts: what each line really does

CodeWhat it does
WiFiClientSecure secured_clientA TCP connection with TLS on top. The s in https.
setCACert(TELEGRAM_CERTIFICATE_ROOT)Installs the root certificate the board needs to trust Telegram's server.
UniversalTelegramBot bot(TOKEN, client)Wraps the Telegram API: your bot's identity plus the secure connection it speaks over.
bot.getUpdates(bot.last_message_received + 1)Asks for messages newer than the last one handled, and returns how many arrived.
bot.messages[i].chat_idWhich conversation a message came from. Reply here to answer the right person.
chat_id != String(CHAT_ID)True when the message came from someone other than the configured id: the actual identity check.
bot.messages[i].textWhat the user typed, ready to compare against your commands.
bot.sendMessage(chat_id, text, "")Sends a reply. The third argument is the parse mode, empty for plain text.
motion && !lastMotionFires once on the rising edge, so one person walking past sends one alert.
String(CHAT_ID).length() > 0The shared escape hatch: an empty id skips both the authorization check and the motion push.
String(t, 1)Formats a float to one decimal place for a readable reply.
millis() - lastBotCheck > BOT_CHECK_INTERVALThe non-blocking timer again, now pacing how often you poll Telegram.
Predict, then check. Predict: if a stranger finds your bot's username and sends /light_on before you've set CHAT_ID, does the light turn on? Check Step 3's logic, then confirm on your own bot (with CHAT_ID still blank).

7Demonstration

Animated demo: Telegram bot responds to /start, /light_on, and /light_off commands
Send /start → "Welcome!" or "Unauthorized". Send /light_on → LED turns on.

🎯 Knowledge Check

1. Why does the Telegram bot use outbound polling instead of hosting a web server?

2. What must you install to make HTTPS work on the ESP32?

3. How does the bot prevent unauthorized users from controlling it?

4. What does getUpdates(last_message_received + 1) do?

5. What pattern prevents Telegram rate-limiting when motion is detected?

9Wrapping Up: What You've Learned

You have built a device with a user interface you did not have to design, reachable from any network in the world. The key takeaways:

  • The best UI is often one people already have. No app to install, no page to style, no instructions beyond /help.
  • Outbound polling beats inbound hosting. Your board opens the connection, so home routers, campus Wi-Fi, and mobile networks all work. A web server reaches only the local network.
  • The + 1 is the whole trick. Asking for updates newer than the last one handled is how a polling client avoids replaying its own history.
  • Both directions matter. Replies are pull, motion alerts are push, and a device that can interrupt you is qualitatively more useful than one you have to check.
  • Edge detection is not optional once a message costs something.
  • HTTPS needs someone to trust. A microcontroller has no certificate store, so you install the root certificate yourself.
  • A public bot still needs its own login check. The token secures the transport, but nothing stops a stranger from finding your bot and typing commands unless you check the sender's chat_id yourself.
  • Compare the three models you have now built: a local page (fast, private, same network only), a cloud dashboard (history and charts, needs an account), and a chat bot (instant, personal, no history).

Now try

  • Inline keyboard buttons: Replace the text commands with Telegram's inline keyboard buttons that users can tap instead of typing.
  • Motion photo alert: When motion is detected, send a message with a timestamp and the current temperature reading alongside the motion alert.

10Troubleshooting

SymptomLikely causeFix
Bot never repliesWrong tokenRe-copy from BotFather
Error on TELEGRAM_CERTIFICATE_ROOTWrong libraryUse UniversalTelegramBot by Brian Lough
JSON compile errorOld ArduinoJsonUpdate to v6 or newer
Replies are slowNormalIt polls once per second
Bot replies "Unauthorized user"CHAT_ID does not match your chatRe-check the id from @userinfobot, digits only, no spaces
No motion alertsCHAT_ID empty or wrongGet your id from @userinfobot
Wi-Fi never connects5 GHz networkESP32 is 2.4 GHz only

11Going further

  • Add a command that returns more sensors: light from an LDR, the current motion state.
  • Robust message parsing. The dispatcher in Step 4 matches commands with plain ==, which is exact: a stray space, different capitalization, or Telegram appending your bot's username in a group chat (/light_on@MyBot instead of /light_on) all fail to match and fall through to "Unknown command". A slightly more forgiving dispatcher trims whitespace and strips anything from @ onward before comparing:
    String text = bot.messages[i].text;
    text.trim();                                 // drop leading/trailing whitespace
    int at = text.indexOf('@');
    if (at != -1) text = text.substring(0, at);  // "/light_on@MyBot" -> "/light_on"
    
    if (text == "/light_on") { ... }
    Do this once, right after reading text, and every existing if/else if below it keeps working unchanged.
  • Inline keyboards. bot.sendMessage() replies with plain text; Universal Telegram Bot's sendMessageWithInlineKeyboard() attaches tappable buttons to the message instead:
    String keyboardJson = "[[{ \"text\" : \"Light ON\", \"callback_data\" : \"/light_on\" },"
                           "  { \"text\" : \"Light OFF\", \"callback_data\" : \"/light_off\" }]]";
    bot.sendMessageWithInlineKeyboard(chat_id, "Choose:", "", keyboardJson);
    Each button carries a callback_data string, which arrives back as its own update (with bot.messages[i].text becoming that string, sent from a tap instead of typed), so the same dispatcher above can handle it with no extra code. This turns /help's list of commands into buttons a user can tap instead of memorize.
  • Rate limits. Telegram doesn't publish exact numbers, but the community‑verified limits are roughly 1 message per second to the same chat, 30 messages per second across different chats, and 20 messages per minute in a group. This project's edge‑detected motion alert and once‑a‑second poll already stay well inside those limits; the thing to watch is any feature that might reply to every message in a burst (like echoing every sensor change) without a check like motion && !lastMotion to space it out. Telegram enforces this with an HTTP 429 Too Many Requests response and a retry_after value telling you how long to wait.
  • Random Nerd Tutorials' Telegram: Control ESP32/ESP8266 Outputs is where the chat id allow list in Step 3 comes from; it also covers the ESP8266 side of the same library if you ever port a sketch to that board.
  • Alvaro Morales' Telegram MQTT bot + ESP32 puts a Raspberry Pi running Node-RED between the bot and several ESP32s over MQTT, so one chat reaches many boards and, notably, warns you the moment one goes offline: a natural next step once Project 11's MQTT and this project's chat are both in your toolbox.
  • Combine this with the Ubidots dashboard from Project 11: dashboard for history, Telegram for quick checks and alerts. Both patterns feed the capstone.