A Telegram bot is one of the fastest ways to get a remote control and an alert channel for MQTT devices: you get a free, cross-platform chat UI, push notifications and no app to build. The bot runs as a small service that does two things — it publishes commands like ON to devices/lamp/set when you type /on lamp, and it subscribes to alert and state topics and forwards them to your chat.
This tutorial builds that bridge in Python with python-telegram-bot and aiomqtt, locks it down to your own chat ID, and tests every path with mosquitto_pub and mosquitto_sub before any hardware is involved.
Architecture
You (Telegram app) ──/on lamp──▶ Telegram API ──▶ bot.py ──PUBLISH testmqtt/tgbot/lamp/set "ON"──▶ broker ──▶ device
device ──PUBLISH testmqtt/tgbot/lamp/state "ON" (retained)──▶ broker ──▶ bot.py (cache for /status)
device ──PUBLISH testmqtt/tgbot/greenhouse/alert "..."──▶ broker ──▶ bot.py ──▶ Telegram chatThe device never talks to Telegram. It only speaks MQTT, so an ESP32 or a Raspberry Pi needs no bot token and no HTTPS client, and you can later add a dashboard or Home Assistant as a second controller on the same topics. The topic layout follows the usual command/state split described in MQTT command and control:
| Topic | Direction | Payload | Retained |
|---|---|---|---|
testmqtt/tgbot/<device>/set | bot → device | ON / OFF | no |
testmqtt/tgbot/<device>/state | device → bot | ON / OFF | yes |
testmqtt/tgbot/<device>/alert | device → bot | free text | no |
Create the bot
- In Telegram, open a chat with @BotFather, send
/newbot, choose a name and a username ending inbot. - Copy the token BotFather returns. It is a secret: anyone with it can impersonate your bot.
- Optionally send
/setcommandsto BotFather withon - switch a device on,off - switch a device off,status - show device states, so the commands appear in the chat menu.
Install the two libraries (Python 3.10+):
pip install "python-telegram-bot>=21" "aiomqtt>=2"Both are asyncio libraries, which is why they fit together: the bot's polling loop and the MQTT connection run in the same event loop, and a command handler can await client.publish(...) directly. See async MQTT in Python with aiomqtt for more on the client.
The bot
import asyncio
import os
import ssl
import time
import aiomqtt
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes
TOKEN = os.environ["TELEGRAM_TOKEN"]
ALLOWED = {int(x) for x in os.environ.get("ALLOWED_CHAT_IDS", "").split(",") if x}
BROKER, PORT = "broker.connectmq.com", 8883
BASE = "testmqtt/tgbot"
DEVICES = {"lamp", "pump", "fan"}
COOLDOWN = 60 # seconds between alerts of the same device
states: dict[str, str] = {}
last_alert: dict[str, float] = {}
async def mqtt_worker(app: Application) -> None:
"""Keep one MQTT connection open, cache states, forward alerts."""
while True:
try:
async with aiomqtt.Client(
BROKER,
PORT,
username=os.environ.get("MQTT_USER"),
password=os.environ.get("MQTT_PASS"),
tls_context=ssl.create_default_context(),
) as client:
app.bot_data["mqtt"] = client
await client.subscribe(f"{BASE}/+/state", qos=1)
await client.subscribe(f"{BASE}/+/alert", qos=1)
async for msg in client.messages:
_, _, device, kind = msg.topic.value.split("/")
text = msg.payload.decode(errors="replace")
if kind == "state":
states[device] = text
continue
now = time.monotonic()
if now - last_alert.get(device, float("-inf")) < COOLDOWN:
continue # debounce noisy sensors
last_alert[device] = now
for chat_id in ALLOWED:
await app.bot.send_message(chat_id, f"⚠️ {device}: {text}")
except aiomqtt.MqttError as err:
app.bot_data.pop("mqtt", None)
print(f"MQTT connection lost ({err}), retrying in 5 s")
await asyncio.sleep(5)
def allowed(update: Update) -> bool:
return update.effective_chat is not None and update.effective_chat.id in ALLOWED
def make_switch(payload: str):
async def handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not allowed(update):
return
if not context.args or context.args[0] not in DEVICES:
await update.message.reply_text(f"Usage: /{payload.lower()} {'|'.join(sorted(DEVICES))}")
return
client = context.bot_data.get("mqtt")
if client is None:
await update.message.reply_text("Broker not connected, try again in a few seconds.")
return
device = context.args[0]
await client.publish(f"{BASE}/{device}/set", payload, qos=1)
await update.message.reply_text(f"Sent {payload} to {device}")
return handler
async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not allowed(update):
return
lines = [f"{d}: {states.get(d, 'unknown')}" for d in sorted(DEVICES)]
await update.message.reply_text("\n".join(lines))
async def whoami(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(f"Your chat ID is {update.effective_chat.id}")
async def post_init(app: Application) -> None:
app.bot_data["worker"] = asyncio.create_task(mqtt_worker(app))
async def post_shutdown(app: Application) -> None:
app.bot_data["worker"].cancel()
def main() -> None:
app = (
Application.builder()
.token(TOKEN)
.post_init(post_init)
.post_shutdown(post_shutdown)
.build()
)
app.add_handler(CommandHandler("on", make_switch("ON")))
app.add_handler(CommandHandler("off", make_switch("OFF")))
app.add_handler(CommandHandler("status", status))
app.add_handler(CommandHandler("whoami", whoami))
app.run_polling()
if __name__ == "__main__":
main()A few design decisions worth noting:
- One MQTT connection for the whole bot. The worker owns the connection and stores it in
bot_data; handlers reuse it instead of connecting per command. If the broker drops, the loop reconnects after five seconds and handlers reply "Broker not connected" in the meantime. - States come from retained messages. Because devices publish
statewith the retain flag, the bot knows every device's last state right after it subscribes, even after a restart. See retained messages. - Unknown chats get silence. Only
/whoamianswers everyone, so you can find your own chat ID. Every command that publishes checks the allowlist first. - Alerts are debounced. A flapping sensor would otherwise hit Telegram's rate limits and drown real alerts.
Run and test without hardware
Start the bot with only TELEGRAM_TOKEN set, send /whoami in the chat and note the number. Restart with the allowlist:
export TELEGRAM_TOKEN="123456:ABC..." # from BotFather
export ALLOWED_CHAT_IDS="111111111" # your chat ID from /whoami
export MQTT_USER="username" MQTT_PASS="password" # if your broker requires a login
python bot.pyNow play the device yourself. In one terminal, watch what the bot publishes:
mosquitto_sub -h broker.connectmq.com -p 1883 -t 'testmqtt/tgbot/+/set' -vSend /on lamp in Telegram. Terminal 1 prints testmqtt/tgbot/lamp/set ON. Report the new state as a device would, then raise an alert:
mosquitto_pub -h broker.connectmq.com -p 1883 -q 1 -r \
-t testmqtt/tgbot/lamp/state -m ON
mosquitto_pub -h broker.connectmq.com -p 1883 -q 1 \
-t testmqtt/tgbot/pump/alert -m 'Water level low (12 %)'/status should now show lamp: ON, and the chat receives ⚠️ pump: Water level low (12 %). Publish the alert again within 60 seconds and confirm it is suppressed. You can also do all of this from the online MQTT client instead of the terminal. Finally, clear the retained state so it does not linger: mosquitto_pub -h broker.connectmq.com -t testmqtt/tgbot/lamp/state -r -n.
Watch the bot's topics in your browser
The device side
On real hardware the device subscribes to its set topic, switches the output and publishes the resulting state with retain. In MicroPython with umqtt.simple the core is a few lines:
from machine import Pin
from umqtt.simple import MQTTClient
lamp = Pin(2, Pin.OUT)
BASE = b"testmqtt/tgbot/lamp"
def on_msg(topic, msg):
lamp.value(1 if msg == b"ON" else 0)
client.publish(BASE + b"/state", msg, retain=True, qos=1)
client = MQTTClient("lamp-7f3a", "broker.connectmq.com", port=1883)
client.set_callback(on_msg)
client.connect()
client.subscribe(BASE + b"/set", qos=1)
while True:
client.wait_msg()Publishing the state after acting is what makes /status trustworthy: the bot reports what the device did, not what you asked for. Add a Last Will on the state or an availability topic so the bot can tell you when a device goes offline.
Secure it
A Telegram bot is reachable by anyone on Telegram, so treat it like a public API:
- Allowlist chats as shown. For groups, the chat ID is negative; add it to the list like any other.
- Keep the token out of code — environment variables or a secret manager, never a Git repository.
- Use TLS to the broker. The example connects on port 8883; plain 1883 is fine only for local experiments.
- Restrict the bot's broker user. It needs write on
.../setand read on.../stateand.../alert, nothing else. Verify the rules with an ACL permission test.
Alternatives without code
If you prefer a visual tool, the same bridge works in n8n (MQTT Trigger → Telegram node, and Telegram Trigger → MQTT node — see MQTT automations with n8n) or in Node-RED with a community Telegram node. The Python version wins when you want a single small service with explicit allowlists, debouncing and tests in your own repository.
| Option | Good for | Trade-off |
|---|---|---|
| Python bot (this guide) | Full control, testable, tiny footprint | You maintain the code |
| n8n workflow | Quick setup, many other integrations | One execution per message |
| Node-RED flow | Already running Node-RED at home | Logic lives in a flow editor |
A Telegram bot turns MQTT into something you can control from your pocket: commands go out as publishes, state comes back as retained messages and alerts arrive as push notifications. Keep the device talking only MQTT, allowlist your chat, debounce alerts, and test the whole loop with mosquitto before you wire up a relay.
Frequently asked questions
Should the device talk to Telegram directly or through MQTT?
Through MQTT. Devices then only need one lightweight connection to the broker, the bot token stays on a server instead of on every device, and you can add other controllers (dashboards, Home Assistant, n8n) without touching firmware.
How do I stop strangers from controlling my devices through the bot?
Telegram bots are public: anyone who finds the username can send commands. Check update.effective_chat.id against an allowlist before publishing anything, and give the bot's broker user an ACL that only allows the command topics it needs.
Why does the bot stop sending alerts when many sensors fire?
Telegram limits how fast a bot can send messages, roughly one per second per chat and lower in groups. Debounce alerts per device, aggregate bursts into one message, and never forward raw high-frequency telemetry to chat.


