برمجة المتحكّمات بلغة مايكروبايثون · الفصل 18 — الرسائل عبر Telegramהודעות דרך Telegram · תכנות מיקרו-בקרים בשפת MicroPython⌂📚🏠
الفصل 18

الرسائل عبر Telegram

הודעות דרך Telegram – Messaging with Telegram
ساعتان تعليميتان

خادم اللوحة في الفصل 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 — لوحة القوانين الشاملة. (נוסחאון הספר)