תכנות מיקרו-בקרים בשפת MicroPython · פרק 18 — הודעות דרך Telegram⌂📚🏠
פרק 18

הודעות דרך Telegram

Messaging with Telegram
2 שעות לימוד

שרת הלוח מפרק 14 נראה רק מתוך רשת הבית. אז איך נדע שהדלת נפתחה כשאנחנו בחוץ? הפתרון הוא להניח ללוח לדבר עם שרת ציבורי שפועל תמיד. בפרק הזה נבנה בוט ב-Telegram: הלוח שולח הודעה לטלפון שלנו, מבצע את הפקודות שנכתוב לו, ושומר על הטוקן שפותח את כל זה.

בפרק הזה:
  • ניצור בוט אצל BotFather ונבין מהם הטוקן ומזהה השיחה.
  • נשמור את הטוקן ב-secrets.py ונדע מה עושים אם הוא דלף.
  • נשלח הודעה בפונקציה requests.post ובפקודה sendMessage.
  • נקבל פקודות בעזרת getUpdates ונבין את תפקיד offset.
  • נבנה בוט שמחזיר קריאה של חיישן ומפעיל נורית, ומציית רק לבעליו.
  • נחשב את מספר הבקשות ונבחר זמן תשאול סביר.

18.1 בוט: חשבון שהלוח מפעיל (Bots and BotFather)

בוט הוא חשבון ב-Telegram שתוכנה כותבת בו במקום אדם. מכיוון שהלוח הוא שיוזם כל חיבור, אין צורך בכתובת ציבורית ואין צורך לשנות דבר בנתב: הבקשה היוצאת עוברת כמו בקשה שיוצאת מן הטלפון.

הלוחבתוך רשת הביתשרת Telegramapi.telegram.orgהטלפוןאפליקציית TelegramHTTPSהודעהgetUpdatesפקודה
הלוח אינו מדבר עם הטלפון ישירות: שניהם מדברים עם שרת Telegram.

יוצרים את הבוט מן הטלפון, בלי שום תכנות:

  1. פותחים ב-Telegram שיחה עם @BotFather, הבוט הרשמי לניהול בוטים.
  2. שולחים את הפקודה /newbot. הוא שואל אותנו מהו שם התצוגה, ואחר כך מהו שם המשתמש, שחייב להסתיים ב-bot.
  3. הוא משיב לנו עם הטוקן. מעתיקים אותו מיד אל secrets.py ולא מדביקים אותו בשום מקום אחר.
  4. פותחים את השיחה עם הבוט החדש, לוחצים Start ושולחים לו הודעה כלשהי.
טוקן: מחרוזת ארוכה ש-BotFather נותן לנו, והיא מוכיחה לשרת Telegram שהבקשה הגיעה מבעל הבוט. צורתה: מספר, נקודתיים, ואחריהם אותיות וספרות.
מזהה שיחה: מספר שמזהה את השיחה שאליה אנחנו שולחים; שמו בבקשה הוא chat_id. מגלים אותו מן ההודעה ששלחנו אל הבוט, כפי שנראה בסעיף 18.4.
הערה: בתוכניות הפרק יש שני ערכים מלאכותיים: הטוקן 123:TEST ומזהה השיחה 555. גם השרת מדומה: התשובות כתובות בעולם הסימולציה, ובזמן בניית הספר לא נשלחה אף בקשה אל Telegram.
שאלה 18.1: למה בוט אינו מחייב שינוי בהגדרות הנתב, ואילו שרת הלוח מחייב אותו כדי שמישהו מבחוץ יגיע אליו?

18.2 הטוקן הוא סיסמה

מי שמחזיק בטוקן מחזיק בבוט: הוא קורא את ההודעות שמגיעות אליו, שולח בשמו ומפקד על הלוח שלנו כרצונו. אין שם משתמש ואין שאלה נוספת, והטוקן לבדו מספיק. לכן מתייחסים אליו כמו אל סיסמת ה-WiFi: מקומו היחיד הוא שורה ב-secrets.py.

הקובץ secrets.py בלוח, עם ערכים מלאכותיים. הקובץ הזה אינו רץ לבדו, ולכן לא הורץ בזמן בניית הספר.
WIFI_SSID = "my-network"
WIFI_PASS = "my-password"
TELEGRAM_TOKEN = "123:TEST"
אזהרה: אם הטוקן דלף, לא מספיק למחוק אותו מן הקובץ, כי העותק שדלף ממשיך להיות תקף.

מה עושים אם הטוקן דלף? לפי הסדר הזה:

  1. מבטלים את הטוקן מיד אצל BotFather: שולחים את הפקודה /token ובוחרים את הבוט, והוא נותן לנו טוקן חדש. התיעוד של Telegram מזכיר את הפקודה הזאת למקרה של טוקן שדלף, ובתפריט הפקודות של BotFather מופיעה גם /revoke.
  2. מציבים את הטוקן החדש ב-secrets.py בכל לוח שמשתמש בו.
  3. בודקים שהטוקן הישן כבר אינו עובד: בקשה שמשתמשת בו צריכה להידחות.
  4. מחפשים את מקור הדליפה וסוגרים אותו. ואם הטוקן נכנס למאגר קוד, הוא נשאר בהיסטוריה שלו גם אחרי מחיקת השורה.
שאלה 18.2: תלמיד שם את הטוקן בתוכנית שלו והעלה אותה ל-GitHub. אחרי דקה הוא שם לב, מחק את השורה והעלה גרסה חדשה. האם הבעיה נפתרה?

18.3 שליחת הודעה (sendMessage)

כל פקודות הבוט הן בקשות HTTPS לכתובת אחת בצורה קבועה: https://api.telegram.org/bot<TOKEN>/METHOD. התשובה היא טקסט JSON ובו השדה ok, ועמו result בהצלחה או description בכישלון.

ההודעה הראשונה: מתחברים לרשת, ואחר כך בקשה אחת. מתחת לפלט מופיע יומן של מה שיצא מן הלוח, ובו רואים את הטוקן בתוך הכתובת.
import time
import network
import requests
from secrets import WIFI_SSID, WIFI_PASS, TELEGRAM_TOKEN

CHAT = 555
API = "https://api.telegram.org/bot" + TELEGRAM_TOKEN

wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.disconnect()
wlan.connect(WIFI_SSID, WIFI_PASS)
while not wlan.isconnected():
    time.sleep(0.5)

r = requests.post(API + "/sendMessage",
                  json={"chat_id": CHAT, "text": "board is up"})
reply = r.json()
if reply["ok"]:
    print(r.status_code, reply["ok"], reply["result"]["message_id"])
else:
    print(r.status_code, reply["description"])
r.close()
ESP32 + Pico W · MicroPython 1.29 · simulated board
200 True 7
[http_sent]
POST https://api.telegram.org/bot123:TEST/sendMessage {'text': 'board is up', 'chat_id': 555}

את הנתונים שולחים בפרמטר json=, והספרייה דואגת להמיר אותם לטקסט JSON, כך שאין צורך לקודד רווחים בכתובת. חשוב לא לשכוח את r.close(): בלעדיו החיבור נשאר פתוח והזיכרון נשאר תפוס.

עכשיו נראה מה קורה אחרי ביטול הטוקן. השרת משיב בקוד 401, ומכאן התוכנית יודעת שהטוקן אינו תקף:

סימולציה של טוקן מבוטל. כך מבצעים את הצעד השלישי ברשימה הקודמת.
from secrets import TELEGRAM_TOKEN
from tgbot import Bot

bot = Bot(TELEGRAM_TOKEN)
code = bot.send(555, "still working?")
print("status", code)
if code == 401:
    print("token rejected: put the new one in secrets.py")
ESP32 + Pico W · MicroPython 1.29 · simulated board
status 401
token rejected: put the new one in secrets.py
הערה: ההצפנה מסתירה את הטוקן ממי שנמצא ברשת. אבל הספרייה requests במיקרו-פייתון אינה בודקת את תעודת השרת בהגדרות ברירת המחדל, ולכן אין ערובה שאנחנו מדברים באמת עם Telegram. זה מקובל בניסוי לימודי, אבל לא במערכת קריטית.
שאלה 18.3: מה מדפיסה התוכנית tg_check_token.py אילו הטוקן היה תקף והשרת היה משיב בקוד 200?

18.4 קבלת פקודות (getUpdates)

השרת אינו יכול ליזום חיבור ללוח, ולכן הוא שומר כל הודעה עד שישאלו עליה. כל הודעה שמגיעה היא עדכון שיש לו מספר update_id, והשאלה החוזרת נקראת תשאול. נכתוב את התוכנית הפשוטה ביותר:

הודעה אחת הגיעה מן הטלפון, והתוכנית רואה אותה בכל פעם מחדש. אילו הפקודה הייתה מחליפה את מצב הנורית, היא הייתה מחליפה אותו בלי הפסקה.
import time
import requests
from secrets import TELEGRAM_TOKEN

API = "https://api.telegram.org/bot" + TELEGRAM_TOKEN

for i in range(3):
    r = requests.get(API + "/getUpdates")
    data = r.json()
    r.close()
    if not data["ok"]:
        print(data["description"])
        break
    for u in data["result"]:
        print("poll", i, u["update_id"], u["message"]["text"])
    time.sleep(5)
ESP32 + Pico W · MicroPython 1.29 · simulated board
poll 0 100 /on
poll 1 100 /on
poll 2 100 /on

הסיבה היא שהשרת אינו יודע שטיפלנו בהודעה. מודיעים לו על כך בפרמטר offset:

אישור עדכונים
offset = updateid + 1
סמלפירושיחידה
updateidמספר העדכון האחרון שטיפלנו בו—
offsetהמספר הקטן ביותר שנרצה לראות בבקשה הבאה—
כל הודעה מטופלת פעם אחת. המספר הראשון בכל שורה הוא מזהה השיחה שחיפשנו.
import time
import requests
from secrets import TELEGRAM_TOKEN

API = "https://api.telegram.org/bot" + TELEGRAM_TOKEN
offset = 0

for i in range(3):
    r = requests.get("%s/getUpdates?offset=%d" % (API, offset))
    data = r.json()
    r.close()
    if not data["ok"]:
        print(data["description"])
        break
    print("poll", i, "new:", len(data["result"]))
    for u in data["result"]:
        offset = u["update_id"] + 1
        m = u["message"]
        print(" ", m["chat"]["id"], m["text"])
    time.sleep(5)
ESP32 + Pico W · MicroPython 1.29 · simulated board
poll 0 new: 2
  555 /on
  555 /light
poll 1 new: 1
  555 /off
poll 2 new: 0
הערה: לפי התיעוד של Telegram: עדכון נחשב מאושר כשמבקשים getUpdates עם offset גדול ממספרו, ועדכון שלא אושר אינו נשמר בשרת יותר מ-24 שעות. לכן אם הלוח היה מנותק יום וחצי, הפקודות של היום הראשון אבדו.
שאלה 18.4: העדכון האחרון שטיפלנו בו הוא מספר 240. מהו ערך offset בבקשה הבאה? ומה יקרה אם נשלח 240?

18.5 בוט שמחזיר קריאה של חיישן ומפעיל נורית

נאגד את הבקשות בספרייה קטנה, כדי שהתוכנית הראשית תישאר ברורה. הספרייה מקבלת את הטוקן מן התוכנית ואינה יודעת מהו ערכו:

הספרייה tgbot.py.
# tgbot.py - a small Telegram bot client over HTTPS.
# The token comes from secrets.py. Never print self.api: it holds the token.
# (c) 2026 Fahed Ghanem - MIT License - fahed-lab.com
import time

import requests


class Bot:
    def __init__(self, token):
        self.api = "https://api.telegram.org/bot" + token
        self.offset = 0

    def send(self, chat, text):
        r = requests.post(self.api + "/sendMessage",
                          json={"chat_id": chat, "text": text})
        code = r.status_code
        r.close()
        return code

    def updates(self):
        url = "%s/getUpdates?offset=%d" % (self.api, self.offset)
        r = requests.get(url)
        code = r.status_code
        data = r.json()
        r.close()
        if code != 200:
            raise OSError(code)
        found = []
        for u in data["result"]:
            self.offset = u["update_id"] + 1
            m = u.get("message")
            if m and "text" in m:
                found.append((m["chat"]["id"], m["text"]))
        return found
3V335 (ESP32)GNDGP27 (Pico W)board.LEDon the boardRLDRRF10 kΩ
הנגד תלוי האור כמו בפרק 6, והנורית שעל הלוח, ולכן אין צורך לחבר אותה.
שתי פקודות מבעל הבוט, ופקודה אחת מזר שאינה מתבצעת, ואחריהן עוד שתי פקודות. בסוף הרשת מתנתקת בסימולציה, והתוכנית מדווחת וממשיכה.
import time
from machine import Pin
import board
from convert import to_percent
from secrets import TELEGRAM_TOKEN
from tgbot import Bot
import wifi

MY_CHAT = 555
POLL_S = 10

led = Pin(board.LED, Pin.OUT)
ldr = board.analog_in(board.LDR)
bot = Bot(TELEGRAM_TOKEN)


def answer(cmd):
    if cmd == "/light":
        return "light %d %%" % to_percent(ldr.read_u16())
    if cmd == "/on":
        led.on()
        return "LED on"
    if cmd == "/off":
        led.off()
        return "LED off"
    return "use /light /on /off"


wifi.connect()
while True:
    try:
        for chat, text in bot.updates():
            if chat != MY_CHAT:
                print("ignored", chat, text)
                continue
            reply = answer(text)
            print(text, "->", reply, "| led", led.value())
            bot.send(chat, reply)
    except OSError as e:
        print("network error", e)
    time.sleep(POLL_S)
ESP32 + Pico W · MicroPython 1.29 · simulated board
/light -> light 40 % | led 0
/on -> LED on | led 1
ignored 999 /off
/light -> light 70 % | led 1
hello -> use /light /on /off | led 1
network error -202
network error -202
...
פקודהמה היא עושה
bot.updates()מחזירה רשימה של זוגות (מזהה שיחה, טקסט), ומעדכנת את offset בעצמה.
chat != MY_CHATמתעלמים מכל מי שאינו בעל הבוט.
except OSErrorניתוק רשת הוא אירוע רגיל: מדווחים עליו ומנסים שוב בסבב הבא.
טעות נפוצה: שם הבוט פומבי, וכל אחד יכול למצוא אותו ולכתוב לו. בלי בדיקת chat זר יכול להפעיל כל מה שמחובר ללוח. וגם אין לשלוח בתשובות לפקודות שום פרט שאיננו רוצים שיקרא מי שמצא את הבוט.
שאלה 18.5: מה ישתנה בפלט הקודם אילו מחקנו את בדיקת chat?

18.6 כל כמה זמן שואלים? (Polling Rate)

כל בקשת HTTPS בספרייה requests פותחת חיבור חדש, עם לחיצת יד של הצפנה שצורכת זמן וזיכרון, ובזמן הזה התוכנית אינה עושה דבר אחר. לכן לא שואלים בכל שנייה.

מספר הבקשות ביום
N = 86400T
סמלפירושיחידה
Nמספר הבקשות ביום—
Tהזמן בין שתי בקשות s
דוגמה 18.1
שואלים את השרת כל 10 s. כמה בקשות ביום? ומהו הזמן הארוך ביותר שפקודה ממתינה עד שתיראה?
פתרון:
N = 86400T
N = 8640010
N = 8640

המקרה הגרוע: הפקודה הגיעה מיד אחרי בקשה, ולכן היא ממתינה מחזור שלם, כלומר 10 s.

N = 8640
זמן התשאולבקשות ביוםההמתנה הארוכה ביותר לפקודה
1 s864001 s
5 s172805 s
10 s864010 s
60 s144060 s
שאלה 18.6: כמה בקשות חוסכים ביום אם עוברים מתשאול בכל שנייה לתשאול כל 30 s?

עיקרי הפרק:

פקודות הפרק:

פקודהמה היא עושה
from secrets import TELEGRAM_TOKENהדרך היחידה שבה הטוקן מגיע אל התוכנית.
POST .../bot<TOKEN>/sendMessageשליחת הודעה; השדות chat_id ו-text.
GET .../bot<TOKEN>/getUpdates?offset=nהעדכונים שמספרם n ומעלה.
/newbotב-BotFather: יצירת בוט.
/tokenב-BotFather: טוקן חדש במקום הטוקן שדלף.
Bot(token)מחלקה מן הספרייה tgbot.py, ולה הפונקציות send ו-updates.

מילון מונחים:

עבריתEnglishערבית
בוטBotبوت
טוקןBot Tokenرمز البوت
מזהה שיחהChat IDمعرّف المحادثة
עדכוןUpdateتحديث
תשאולPollingالاستطلاع
ביטול טוקןToken Revocationإلغاء الرمز
התשובות הסופיות לשאלות הפרק
18.1 כי בבוט הלוח הוא שיוזם את החיבור כלפי חוץ, ואילו השרת ממתין לחיבור שמגיע מבחוץ.
18.2 לא. הטוקן נשאר בהיסטוריית המאגר, ואולי מישהו כבר העתיק אותו. יש לבטל אותו אצל BotFather ולהציב טוקן חדש.
18.3 היא מדפיסה status 200 בלבד, כי התנאי אינו מתקיים.
18.4 הערך 241. אם נשלח 240, אותו עדכון יחזור ונטפל בו בשנית.
18.5 הפקודה של הזר 999 תתבצע: הנורית תכבה, והתשובה תישלח גם אליו.
18.6 86400 − 2880 = 83520 בקשות.

שאלות לתרגול:

רמה ראשונה — בסיס
1.
מה שם הקובץ שבו כותבים את הטוקן? ואיזו שורה מביאה אותו אל התוכנית?
תשובות: secrets.py, והשורה from secrets import TELEGRAM_TOKEN.
2.
בתשובה שקיבלנו, העדכון האחרון הוא מספר 57. מה מציבים ב-offset?
תשובות: הערך 58.
3.
מתשאלים כל 20 s. כמה בקשות ביום?
תשובות: N = 4320.
רמה שנייה — יישום
4.
חבר לכיתה מצא את הטוקן של הבוט בצילום מסך שפרסמתם. סדרו את מה שאתם עושים.
תשובות: ביטול הטוקן אצל BotFather, אחר כך הצבת הטוקן החדש ב-secrets.py, אחר כך בדיקה שהישן נדחה, ואחר כך מחיקת הצילום.
5.
שנו את לולאת הבוט כך שתקבל פקודות משני מזהים: 555 ו-777.
תשובות:
ALLOWED = (555, 777)

for chat, text in bot.updates():
    if chat not in ALLOWED:
        continue
    bot.send(chat, answer(text))
6.
הוסיפו לפונקציה answer את הפקודה /status שמחזירה את מצב הנורית.
תשובות: מוסיפים את התנאי if cmd == "/status":, ואחר כך מחזירים "LED %d" % led.value().
רמה שלישית — העמקה
7.
כתבו תוכנית ששולחת התראה אחת כשהאור יורד מתחת ל-20 %, ואינה שולחת התראה נוספת לפני שהאור חוזר ועולה מעל 30 %. למה שני ספים?
תשובות:
import time
import board
from convert import to_percent
from secrets import TELEGRAM_TOKEN
from tgbot import Bot

MY_CHAT = 555
ldr = board.analog_in(board.LDR)
bot = Bot(TELEGRAM_TOKEN)
dark = False

while True:
    level = to_percent(ldr.read_u16())
    if level < 20 and not dark:
        dark = True
        bot.send(MY_CHAT, "it is dark: %d %%" % level)
    elif level > 30:
        dark = False
    time.sleep(10)
שני הספים מונעים הצפה של הודעות כשהאור מתנדנד ליד ערך אחד.
8.
בוט מתשאל בכל שנייה, וכל בקשה חוסמת את הלוח כ-0.2 s בסימולציה. כמה שניות ביום הולכות להמתנה? וכמה אילו התשאול היה כל 10 s?
תשובות: 86400 · 0.2 = 17280 s, ו-8640 · 0.2 = 1728 s.
נוסחאון הספר: כל נוסחאות הפרק הזה מרוכזות, עם סרטוטיהן וטבלאות הסמלים שלהן, בפרק 25 — נוסחאון הספר.