ربات تلگرام هوش مصنوعی با وبسرویس؛ حافظه، استریم و سقف خرج
ساختن یک ربات تلگرام که به هوش مصنوعی وصل باشد، نسخهٔ اولش نیم ساعت طول میکشد. مشکل از نسخهٔ دوم شروع میشود: کاربر میپرسد «قیمتش چند بود؟» و ربات نمیداند «آن» چه بود، چون هیچ حافظهای ندارد. بعد یک نفر پیدا میشود که نصف شب صد پیام میفرستد و صبح میبینید اعتبار حساب خالی شده. و در آخر، کاربرها از سکوت ده ثانیهای ربات خسته میشوند و پیام را دوباره میفرستند.
این راهنما هر سه را حل میکند. یک ربات کامل با وبسرویس (API) میسازیم: وبهوک امن، حافظهٔ گفتوگو با کوتاهسازی، پاسخ تکهتکه به شکلی که تلگرام واقعاً اجازه میدهد، و سقف مصرف روزانه برای هر کاربر.
وبهوک یا long polling؟
دو راه برای گرفتن پیامهای تلگرام وجود دارد و انتخابشان بقیهٔ معماری را تعیین میکند:
| موضوع | long polling | وبهوک |
|---|---|---|
| چطور کار میکند | برنامهٔ شما مدام از تلگرام میپرسد «پیام تازهای هست؟» | تلگرام هر پیام را به نشانی شما میفرستد |
| چه چیزی لازم دارد | یک فرایند همیشهروشن | یک نشانی HTTPS روی دامنهٔ شما |
| روی هاست اشتراکی | معمولاً نمیشود | بهراحتی میشود |
| تأخیر | وابسته به فاصلهٔ پرسشها | تقریباً بیدرنگ |
| مناسب برای | توسعه و آزمایش روی لپتاپ | ربات منتشرشده |
برای توسعه long polling راحتتر است، چون نشانی عمومی نمیخواهد. برای انتشار، وبهوک هم ارزانتر تمام میشود و هم پایدارتر. ثبتش یک دستور است و حتماً با secret_token، وگرنه هر کسی که نشانی شما را حدس بزند میتواند پیام جعلی بفرستد:
curl "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \
-d "url=https://example.com/bot/webhook.php" \
-d "secret_token=$TG_WEBHOOK_SECRET" \
-d 'allowed_updates=["message"]'
یک نکتهٔ فنی که وقت زیادی از تیمها میگیرد: نشانی وبهوک باید HTTPS با گواهی معتبر باشد و تلگرام فقط چند درگاه مشخص را میپذیرد. اگر روی لپتاپ کار میکنید و نشانی عمومی ندارید، برای همان چند ساعت یک تونل موقت بالا بیاورید، یا با long polling جلو بروید و فقط هنگام انتشار به وبهوک سوییچ کنید.
ربات در یک فایل PHP
این فایل بدون فریمورک کار میکند و روی هر هاستی که PHP و cURL داشته باشد اجرا میشود. سه چیز را از همان اول رعایت کرده: بررسی امضای وبهوک، بستن سریع پاسخ به تلگرام، و شمردن توکنهای هر کاربر.
<?php
// webhook.php
const AI_URL = 'https://netarz.ir/api/ai/v1/chat/completions';
const TG_URL = 'https://api.telegram.org/bot';
const DAILY_TOKEN_BUDGET = 40000; // سقف مصرف هر کاربر در روز
if (! hash_equals((string) getenv('TG_WEBHOOK_SECRET'), $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '')) {
http_response_code(403);
exit;
}
$update = json_decode(file_get_contents('php://input'), true) ?: [];
$chatId = $update['message']['chat']['id'] ?? null;
$text = trim((string) ($update['message']['text'] ?? ''));
http_response_code(200); // تلگرام را همینجا آزاد کنید، پیش از کار سنگین
if (! $chatId || $text === '') {
exit;
}
if ($text === '/start') {
tg('sendMessage', ['chat_id' => $chatId, 'text' => 'سلام. سؤالتان را بنویسید.']);
exit;
}
if ($text === '/reset') {
@unlink(storePath($chatId));
tg('sendMessage', ['chat_id' => $chatId, 'text' => 'گفتوگو پاک شد.']);
exit;
}
if (spentToday($chatId) >= DAILY_TOKEN_BUDGET) {
tg('sendMessage', ['chat_id' => $chatId, 'text' => 'سهمیهٔ امروز شما تمام شد. فردا دوباره در خدمتتان هستیم.']);
exit;
}
tg('sendChatAction', ['chat_id' => $chatId, 'action' => 'typing']);
$history = loadHistory($chatId);
$history[] = ['role' => 'user', 'content' => $text];
[$answer, $tokens] = ask($history);
$history[] = ['role' => 'assistant', 'content' => $answer];
saveHistory($chatId, $history);
addSpent($chatId, $tokens);
tg('sendMessage', ['chat_id' => $chatId, 'text' => $answer]);
و توابع کمکی، که همهٔ منطق واقعی در آنهاست:
function ask(array $history): array
{
$messages = array_merge(
[['role' => 'system', 'content' => 'You are the support agent of an Iranian online shop. Reply in short, polite, spoken Persian.']],
recentTurns($history, 10)
);
$ch = curl_init(AI_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer '.getenv('NETARZ_API_KEY'),
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'gpt-4o-mini',
'messages' => $messages,
'max_tokens' => 400,
], JSON_UNESCAPED_UNICODE),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$body = json_decode((string) $raw, true) ?: [];
if ($status !== 200) {
error_log('ai error '.$status.' '.($body['error']['code'] ?? 'unknown'));
return ['الان نمیتوانم جواب بدهم؛ چند دقیقهٔ دیگر دوباره بپرسید.', 0];
}
return [
trim((string) ($body['choices'][0]['message']['content'] ?? '')),
(int) ($body['usage']['total_tokens'] ?? 0),
];
}
function recentTurns(array $history, int $keep): array
{
return array_slice($history, -$keep);
}
function storePath(int $chatId): string { return sys_get_temp_dir()."/tg_{$chatId}.json"; }
function loadHistory(int $chatId): array
{
$raw = @file_get_contents(storePath($chatId));
return $raw ? (json_decode($raw, true) ?: []) : [];
}
function saveHistory(int $chatId, array $history): void
{
file_put_contents(storePath($chatId), json_encode(array_slice($history, -20), JSON_UNESCAPED_UNICODE));
}
function tg(string $method, array $params): void
{
$ch = curl_init(TG_URL.getenv('BOT_TOKEN').'/'.$method);
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($params, JSON_UNESCAPED_UNICODE),
]);
curl_exec($ch);
curl_close($ch);
}
فایل موقت برای نگهداری تاریخچه فقط برای شروع خوب است. در نسخهٔ منتشرشده تاریخچه و شمارندهٔ مصرف را در دیتابیس یا Redis بگذارید، وگرنه با پاک شدن پوشهٔ موقت سرور، حافظهٔ همهٔ کاربران میپرد.
چرا پاسخ ۲۰۰ را پیش از کار سنگین میبندیم؟ چون تلگرام اگر جوابی نگیرد همان پیام را دوباره میفرستد؛ کاربر دو بار جواب میگیرد و شما دو بار هزینه میدهید. در PHP-FPM بعد از بستن پاسخ میتوانید با fastcgi_finish_request() بقیهٔ کار را ادامه بدهید، و روی بار بالاتر پیام را در صف بگذارید تا یک کارگر پسزمینه جوابش را بدهد. مهلت اجرای درخواست وب را هم بیشتر از تایماوت تماس با مدل بگذارید، وگرنه وسط کار قطع میشوید.
حافظهٔ گفتوگو، بدون منفجر کردن هزینه
مدل حافظه ندارد؛ هر بار کل گفتوگو را دوباره میفرستید. یعنی پیام بیستم، هزینهٔ نوزده پیام قبلی را هم با خودش حمل میکند. سه قاعده این رشد را مهار میکنند:
- فقط چند نوبت آخر را بفرستید. ده پیام آخر برای پشتیبانی فروشگاه تقریباً همیشه کافی است.
- گفتوگوی طولانی را یک بار خلاصه کنید. وقتی تاریخچه از بیست پیام گذشت، با یک درخواست جداگانه خلاصهای بسازید و آن را جای پیامهای قدیمی بگذارید.
- حافظه را منقضی کنید. اگر کاربر دو ساعت چیزی نگفته، گفتوگوی تازه شروع کنید. هم ارزانتر است و هم پاسخها دقیقتر میشوند.
یک دستور /reset هم بگذارید. کاربران وقتی میبینند ربات موضوع را عوض نمیکند، خودشان از آن استفاده میکنند و شما در هزینه صرفهجویی میکنید. شمارش دقیق توکنها و مثالهای عددی در توکن چیست و هزینه چطور حساب میشود آمده است.
دستور سیستم را هم جدی بگیرید؛ بیشتر شکایتهای «ربات پرتوپلا میگوید» از همینجا میآید. در همان دستور بنویسید ربات نمایندهٔ چه کسی است، چه چیزهایی را میداند، و وقتی جواب را نمیداند چه بگوید. اگر قرار است دربارهٔ قیمت و موجودی حرف بزند، آن دادهها را در همان درخواست به او بدهید؛ مدل از خودش چیزی دربارهٔ فروشگاه شما نمیداند و جای خالی را با حدس پر میکند.
استریم یا یک پاسخ؟ واقعیت تلگرام
تلگرام پیام تکهتکه ندارد. چیزی که در رباتهای خوب میبینید این است: یک پیام کوتاه فرستاده میشود و بعد با editMessageText پشتسرهم بهروزرسانی میشود. این کار شدنی است، ولی سقف دارد؛ ویرایشهای پیدرپی در یک گفتوگو محدودند و اگر تند ویرایش کنید، تلگرام درخواستها را رد میکند.
قاعدهای که در عمل جواب میدهد:
- پاسخهای کوتاه (زیر چند خط): اصلاً استریم نکنید. فقط
sendChatActionبا حالت typing بفرستید و پاسخ کامل را یکجا بدهید. - پاسخهای بلند: استریم بگیرید، ولی هر حدود یک ثانیه یا هر چند ده نویسه یک بار ویرایش کنید، نه بهازای هر تکه.
- در هر حالت: متن نهایی را یک بار دیگر کامل بنویسید تا اگر ویرایشی جا افتاد، کاربر متن ناقص نبیند.
یک نکتهٔ هزینه هم هست: اگر استریم را نیمهکاره قطع کنید، آنچه تا آن لحظه تولید شده محاسبه میشود. پس «کنسل» کردن پاسخ، هزینه را به صفر برنمیگرداند.
تجربهٔ کاربری را هم فراموش نکنید. حالت typing بعد از چند ثانیه منقضی میشود، پس برای پاسخهای طولانی دوباره بفرستیدش تا کاربر فکر نکند ربات خوابیده. همین یک کار ساده، تعداد پیامهای تکراری را بهطور محسوسی کم میکند.
سقف مصرف هر کاربر
این مهمترین بخشی است که معمولاً جا میماند. ربات عمومی یعنی هر کسی میتواند از اعتبار شما خرج کند. سه لایه بگذارید:
- سقف روزانهٔ هر کاربر: همان
DAILY_TOKEN_BUDGETدر کد بالا. مجموعtotal_tokensهر پاسخ را به حساب کاربر اضافه کنید و وقتی رد شد، مؤدبانه جواب بدهید. - سقف هزینهٔ کلید: در پنل برای کلید همین ربات یک سقف بگذارید تا اشکالی در کد، بودجهٔ بقیهٔ پروژهها را نخورد.
- بودجهٔ ماهانهٔ پروژه: ربات را پروژهٔ جداگانه تعریف کنید تا مصرفش در گزارشها از بقیه جدا دیده شود.
function spentToday(int $chatId): int
{
$row = @json_decode((string) @file_get_contents(sys_get_temp_dir()."/spend_{$chatId}.json"), true) ?: [];
return ($row['date'] ?? '') === date('Y-m-d') ? (int) ($row['tokens'] ?? 0) : 0;
}
function addSpent(int $chatId, int $tokens): void
{
file_put_contents(
sys_get_temp_dir()."/spend_{$chatId}.json",
json_encode(['date' => date('Y-m-d'), 'tokens' => spentToday($chatId) + $tokens])
);
}
الگوهای کاملتر مهار خرج، از انتخاب مدل تا هشدار موجودی، در کنترل هزینهٔ API هوش مصنوعی جمع شدهاند.
یک تلهٔ دیگر هم در گروهها هست: شناسهٔ گفتوگو با شناسهٔ فرستنده یکی نیست. اگر ربات در گروه کار میکند سقف مصرف را روی شناسهٔ فرستنده بگذارید، وگرنه یک نفر سهمیهٔ کل گروه را تمام میکند. طول پیام ورودی را هم محدود کنید؛ کسی که یک متن چند صفحهای جایگذاری میکند، در یک درخواست به اندازهٔ دهها گفتوگوی عادی خرج میتراشد.
همین ربات با Python
اگر Python راحتترتان است، کتابخانهٔ python-telegram-bot همین ساختار را کوتاهتر میکند و برای توسعه با long polling اجرا میشود. نسخهٔ کامل با استریم و ویرایش پیام را در ساخت ربات تلگرام فارسی با API هوش مصنوعی نوشتهایم؛ همان کد با عوض کردن نشانی پایه روی درگاه ما هم کار میکند. انتخاب زبان اینجا واقعاً سلیقه است، چون کل تفاوت در چند خط است.
پیش از انتشار
- خطاها را دستهبندی کنید. ۴۲۹ از جنس سرعت تلاش مجدد میخواهد، ۴۰۲ اعتبار میخواهد و ۴۰۱ کلید. تفکیکشان در خطاهای رایج API هوش مصنوعی.
- پیام خطا را انسانی بنویسید. کاربر تلگرام نباید کد وضعیت ببیند؛ یک جملهٔ روشن کافی است.
- ورودی را محدود کنید. پیامهای خیلی طولانی را کوتاه کنید یا مؤدبانه رد کنید، وگرنه یک متن ده صفحهای هزینهٔ یک روز را میبرد.
- لاگ بگیرید. شناسهٔ درخواست از سربرگ
X-Request-Idرا کنار شناسهٔ کاربر ذخیره کنید تا پیگیری ساده باشد. - دربارهٔ حریم خصوصی روشن باشید. نزد ما متن پیامها و پاسخها ذخیره نمیشود، ولی اگر خودتان تاریخچه را نگه میدارید، در پیام
/startهمین را بگویید.
یک قاعدهٔ ساده هم برای پیام خطا: هر جا ربات نمیتواند جواب بدهد، بگویید چه شد و کاربر چه کند. سکوت یا پیام مبهم، همان چیزی است که کاربر را به فرستادن دوبارهٔ پیام و شما را به پرداخت دوبارهٔ هزینه میکشاند.
نسخهٔ پایتون این ربات در گیتهاب
ربات این مقاله با PHP نوشته شده. نسخهٔ پایتونی که همان منطق حافظه را دارد، در telegram-bot/bot.py مخزن نمونهکدهای ما آماده است: پیامهای هر گفتوگو را در یک صف با طول ثابت نگه میدارد تا هزینه با طولانی شدن گفتوگو بالا نرود، و متن جواب را به سقف ۴۰۹۶ نویسهٔ تلگرام میبُرد. پیش از انتشار، سقف هزینهٔ کلید را در پنل بگذارید. فهرست بقیهٔ نمونهها را در کدهای متنباز نِت اَرز میبینید.
قدم بعدی
ترتیب پیشنهادی ما این است: اول نسخهٔ بدون حافظه را منتشر کنید و ببینید کاربران واقعاً چه میپرسند، بعد حافظه و سقف مصرف را اضافه کنید، و در آخر سراغ استریم بروید. اگر ربات قرار است به سفارشها یا موجودی انبار وصل شود، از همان ابتدا ابزاردهی (function calling) را در نظر بگیرید تا مدل بهجای حدس زدن، از سیستم شما بپرسد.
برای مرور مفاهیم پایه وبسرویس هوش مصنوعی چیست را ببینید و فهرست مدلهای فعال با نرخشان در صفحهٔ وبسرویس هوش مصنوعی منتشر میشود. برای ربات پشتیبانی معمولاً یک مدل سبک کافی است و فقط برای پرسشهای پیچیده سراغ مدل بزرگتر بروید.
پرسشهای پرتکرار
ربات تلگرام روی هاست ایران هم کار میکند؟
بله، اگر از وبهوک استفاده کنید و درگاه سازگار را صدا بزنید. تلگرام پیام را به نشانی HTTPS شما میفرستد و سرور شما هم بهجای نشانی رسمی، درخواست را به درگاه میفرستد؛ پس خطای منطقهای پیش نمیآید.
چرا ربات پیام قبلی را یادش نمیماند؟
چون مدل بین درخواستها حافظه ندارد و فقط چیزی را میداند که در همان درخواست فرستادهاید. باید تاریخچهٔ گفتوگو را خودتان ذخیره کنید و در هر نوبت چند پیام آخر را همراه پرسش تازه بفرستید.
چطور جلوی یک کاربر پرمصرف را بگیرم؟
مجموع total_tokens هر پاسخ را به حساب همان کاربر اضافه کنید و یک سقف روزانه بگذارید؛ وقتی رد شد، بهجای درخواست تازه یک پیام مؤدبانه بفرستید. در کنارش برای کلید ربات سقف هزینه و برای پروژه بودجهٔ ماهانه تعریف کنید.
میشود پاسخ را مثل ChatGPT تکهتکه نشان داد؟
تقریباً. تلگرام استریم ندارد، ولی میتوانید یک پیام بفرستید و با editMessageText بهروزرسانی کنید. ویرایشهای پیدرپی محدودند، پس هر حدود یک ثانیه یک بار ویرایش کنید و متن نهایی را یک بار کامل بنویسید.
نظر خوانندگان
هنوز نظری ثبت نشده. اگر این مقاله پرسشتان را جواب داد یا جای چیزی در آن خالی ماند، همینجا بنویسید.