3. الكود
بوت ملاحظات صغير: /note buy milk يحفظ ملاحظة، و/notes يعرض آخر خمس ملاحظات لك.
requirements.txt
aiogram>=3,<4
aiomysql
bot.py
import asyncio
import logging
import os
import sys
import aiomysql
from aiogram import Bot, Dispatcher
from aiogram.filters import Command, CommandObject
from aiogram.types import Message
dp = Dispatcher()
@dp.message(Command("note"))
async def note(message: Message, command: CommandObject, pool: aiomysql.Pool) -> None:
text = (command.args or "").strip()
if not text:
await message.answer("Write your note after the command, for example: /note buy milk")
return
if len(text) > 500:
await message.answer("A note can be up to 500 characters.")
return
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"INSERT INTO notes (user_id, text) VALUES (%s, %s)",
(message.from_user.id, text),
)
await message.answer("Saved. Send /notes to see your last five.")
@dp.message(Command("notes"))
async def notes(message: Message, pool: aiomysql.Pool) -> None:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"SELECT text FROM notes WHERE user_id = %s ORDER BY id DESC LIMIT 5",
(message.from_user.id,),
)
rows = await cur.fetchall()
if not rows:
await message.answer("You have no notes yet.")
return
await message.answer("\n".join(f"- {text}" for (text,) in rows))
async def main() -> None:
# One pool for the whole bot. The values come from the Environment pane.
pool = await aiomysql.create_pool(
host=os.environ["DB_HOST"],
port=int(os.environ["DB_PORT"]),
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
db=os.environ["DB_NAME"],
autocommit=True,
maxsize=5,
)
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"CREATE TABLE IF NOT EXISTS notes ("
"id INT AUTO_INCREMENT PRIMARY KEY, "
"user_id BIGINT NOT NULL, "
"text VARCHAR(500) NOT NULL, "
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
"KEY user_id (user_id))"
)
print("Database ready")
bot = Bot(os.environ["BOT_TOKEN"])
try:
# Every keyword passed here reaches the handlers that ask for it by name.
await dp.start_polling(bot, pool=pool)
finally:
pool.close()
await pool.wait_closed()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
asyncio.run(main())
ماذا يفعل: الدالة main() تفتح المجمّع وتنشئ جدول notes إن كان مفقوداً وتطبع Database ready قبل أن يطلب البوت الرسائل من Telegram. dp.start_polling(bot, pool=pool) هي طريقة aiogram الخاصة لمشاركة كائن: كل وسيط مسمّى إضافي تمرّره يصبح بيانات يسلّمها aiogram لأي معالج لديه معامل بهذا الاسم، لذلك تحصل note() وnotes() على المجمّع نفسه. وعندما يتوقف البوت، تغلق الكتلة finally المجمّع.
يملأ المشغّل (driver) العناصر النائبة %s، لذلك تُخزَّن ملاحظة مثل '); DROP TABLE notes; -- كنص عادي. في اختبارنا حُفظت في الصف 2 وبقي الجدول. لا تبنِ استعلام SQL أبداً بـ f-string.
خزّن معرّفات Telegram بنوع BIGINT
تقول وثائق Bot API الخاصة بـ Telegram إن معرّف المستخدم أو المحادثة «قد يحتوي على أكثر من 32 بتاً معنوياً» لكنه لا يتجاوز 52، لذا فالعدد الصحيح ذو 64 بت آمن. وتعطي صفحتها عن المعرّفات النطاقات: تصل معرّفات المستخدمين إلى 1,099,511,627,775، بينما معرّفات المجموعات والمجموعات الخارقة والقنوات سالبة. وتمتد المجموعات الخارقة والقنوات من -1,000,000,000,001 إلى -1,997,852,516,352 (مثلاً -1001234567890). أما INT في MySQL فيتوقف عند 2,147,483,647. في اختبارنا، وبنفس وضع SQL الصارم الذي يستخدمه خادم قاعدة بياناتنا، فشل حفظ معرّف المستخدم 7,000,000,001 في عمود INT فشل. وقد أطلق aiomysql:
pymysql.err.DataError: (1264, "Out of range value for column 'user_id' at row 1")
أما عمود BIGINT فقد حفظه، وحفظ -1001234567890 أيضاً. استخدم BIGINT العادي لا BIGINT UNSIGNED، حتى تتسع معرّفات المحادثات أيضاً.
4. شغّله
- ارفع
bot.py وrequirements.txt في الملفات.
- اضغط تشغيل وافتح وحدة التحكم. يثبّت التشغيل الحزم من
requirements.txt ثم يعرض [PlayNHost] Starting your bot، Database ready وRun polling for bot @your_bot. وهذا السطر الأخير يحوّل الحالة في نظرة عامة إلى متصل في Telegram.
- في Telegram، أرسل إلى بوتك
/note buy milk ثم /notes. ولعرض الصفوف، افتح صفحة القاعدة وانقر على فتح في phpMyAdmin.
ومنذ التشغيل الثاني تعرض وحدة التحكم أيضاً Warning: Table 'notes' already exists، ويسبقه مسار الملف cursors.py الخاص بـ aiomysql. وهذه ملاحظة من MariaDB بخصوص CREATE TABLE IF NOT EXISTS، ينقلها المشغّل (driver). لا يوجد خطأ.
في اختبارنا أرسل بديل Telegram هذه الأوامر وتلقّى هذه الردود:
| المُرسَل | الرد |
/note buy milk | Saved. Send /notes to see your last five. |
/note | Write your note after the command, for example: /note buy milk |
/note و501 حرفاً | A note can be up to 500 characters. |
/notes | الملاحظات المحفوظة، الأحدث أولاً |
/notes من مستخدم ثانٍ | You have no notes yet. |
رسائل كثيرة في وقت واحد
يعالج aiogram كل تحديث وارد كمهمة مستقلة، لذلك يمكن لعدة أوامر تنفيذ استعلاماتها في الوقت نفسه. في اختبارنا عولجت 4 أوامر وصلت معاً، كلٌّ منها في نحو 50 مللي ثانية. maxsize=5 يحدّ المجمّع بـ 5 اتصالات؛ ويقف الاستعلام السادس بانتظار اتصال حرّ بدلاً من فتح اتصال آخر. بعد الاختبار كان لدى البوت 2 اتصال مفتوح.
فخ autocommit
يفتح aiomysql الاتصالات مع إيقاف autocommit. عندها يبقى أمر INSERT داخل معاملة مفتوحة. وعندما يعود الاتصال إلى المجمّع وما زالت المعاملة مفتوحة، يغلقه aiomysql، فتتخلص MariaDB من الصف غير المثبَّت. في اختبارنا، صف أُدرج في مجمّع دون autocommit=True رآه 0 اتصال آخر. إمّا مرّر autocommit=True إلى create_pool، كما في الأعلى، أو استدعِ await conn.commit() بعد عمليات الكتابة.
فشل استعلام لا يوقف البوت
في اختبارنا حذفنا الجدول والبوت يعمل، وأرسلنا /notes. سجّل aiogram الخطأ مع traceback ينتهي بـ pymysql.err.ProgrammingError: (1146, "Table '...notes' doesn't exist")، ولم يرسل أي رد على ذلك الأمر وواصل العمل: بعد عودة الجدول حُفظ أمر /note التالي. من أرسل الأمر لا يرى شيئاً، لذا إن كان من الممكن أن يفشل استعلام فاعترض الخطأ في المعالج وردّ برسالة قصيرة.
ليلة هادئة لا تكسر المجمّع
يغلق خادم قاعدة بياناتنا أي اتصال خامل لمدة 8 ساعات. والبوت الذي لا يتلقى رسائل كل هذه المدة، ليلاً مثلاً، يجد اتصالاته في المجمّع مغلقة. يتحقق aiomysql من ذلك حين يطلب المعالج اتصالاً، ويفتح اتصالاً جديداً بدل المغلق. اختبرنا ذلك بعد خفض حد الخمول إلى 5 ثوانٍ: وبعد 7 ثوانٍ من الهدوء حُفظ أمر /note التالي دون أي خطأ.
حدود تهم المجمّع لديك
- 20 اتصالاً في وقت واحد لكل مستخدم قاعدة بيانات. كل عملية تستخدم بيانات الدخول نفسها تُحسب. ويُرفض الاتصال 21 مع
(1226, "User '...' has exceeded the 'max_user_connections' resource (current value: 20)"). maxsize=5 يُبقي البوت أدنى من ذلك بكثير.
- الاستعلام الذي يعمل أكثر من 120 ثانية يُوقَف. تنهيه MariaDB بالخطأ 1969،
Query execution was interrupted (max_statement_time exceeded). وفي اختبار سابق لنا، على حساب مُعدّ بالطريقة نفسها، حدث ذلك بعد 120.03 ثانية. تستغرق استعلامات البوت عادة أجزاء من الثانية، لذا لا يؤثر هذا إلا في استعلام خارج عن السيطرة.
- 500 MB لكل قاعدة بيانات. تعرض صفحة قواعد البيانات مقدار ما استُخدم. عند 500 MB تتوقف عمليات الكتابة حتى تحذف صفوفاً.
- النسخ الاحتياطية للخادم لا تتضمن قاعدة البيانات. صدّرها من phpMyAdmin من حين لآخر.
الأخطاء ومعناها
هذه هي الأخطاء التي أعطاها aiomysql في اختبارنا، مكتوبة كما تنتهي بها الـ traceback في وحدة التحكم (وهي قادمة من PyMySQL الذي يستخدمه aiomysql في الخلفية):
pymysql.err.OperationalError: (1045, "Access denied for user ..."): خطأ في DB_USER أو DB_PASSWORD. وبعد إعادة تعيين كلمة المرور على بطاقة الاتصال تتوقف كلمة المرور القديمة عن العمل فوراً: استبدل DB_PASSWORD ثم أعد تشغيل البوت.
pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on ..."): مضيف أو منفذ خاطئ، أو اتصال من خارج PlayNHost. لا تقبل القاعدة إلا الاتصالات من خوادم PlayNHost ومن phpMyAdmin، لذا فهذا ما ستحصل عليه أيضاً على حاسوبك الشخصي.
pymysql.err.OperationalError: (1226, ... 'max_user_connections' ...): عدد كبير جداً من الاتصالات المفتوحة، راجع الحدود أعلاه. مثلاً نسخة ثانية من البوت، أو مجمّع يُفتح لكل رسالة.
pymysql.err.DataError: (1264, "Out of range value for column 'user_id' at row 1"): عمود المعرّف من نوع INT. غيّره إلى BIGINT.
pymysql.err.ProgrammingError: (1146, "Table '...' doesn't exist"): الجدول مفقود. ينشئه الكود أعلاه عند البدء؛ وإن أنشأته يدوياً فتحقق من اسمه.
متغير مفقود من البيئة يوقف البوت قبل أن يتصل، مع KeyError: 'DB_HOST' أو اسم المتغير المفقود، لأن الكود يقرأ كل قيمة بـ os.environ[...]. أضفه ثم أعد تشغيل البوت: تُطبَّق المتغيرات عند التشغيل التالي.
يظهر الرمز الخاطئ بعد جزء قاعدة البيانات. مع رمزنا المزيف أمام Telegram الحقيقي، طبع البوت Database ready، ثم توقف مع aiogram.exceptions.TelegramUnauthorizedError: Telegram server says - Unauthorized. انسخ الرمز مجدداً من @BotFather والصقه في صفحة بوت، جزء Telegram. مزيد من أخطاء Telegram: إصلاح خطأ 409 Conflict في بوت Telegram.
كم يستهلك من الذاكرة
في اختبارنا استهلك البوت العامل 174 MB من الذاكرة (resident) بعد أن ردّ على الأوامر الـ 8، مع بقاء مجمّعه مفتوحاً، داخل حاوية محدودة بـ 512 MB مثل أصغر خطة بوت لدينا. ومعظم ذلك هو aiogram نفسه: ففي اختبار الاستضافة السابق لدينا قاس aiogram وحده 173 MB (توقف ذلك البوت عند رمز مرفوض، قبل معالجة أي رسالة، لذا فالرقمان متقاربان لكنها ليست مقارنة دقيقة). وبلغ تثبيت aiogram وaiomysql بـ pip من الصفر ذروة 163 MB، مع ذاكرة الملفات المؤقتة. وهذا يترك مجالاً لكودك على أصغر خطة. ويقارن دليل الاستضافة بين المكتبات: python-telegram-bot مقابل aiogram على PlayNHost.
الأمر نفسه مع مكتبة أخرى
هل تحتاج إلى خادم لبوت Telegram الخاص بك مع قاعدة بيانات مضمّنة؟ اطّلع على خطط بوتات Telegram.