Phát triển bot
Webhook
Cách để máy chủ gửi thẳng tin mới đến địa chỉ HTTPS của bot thay vì dùng getUpdates — thiết lập, xác minh, gửi lại và trả lời ngay trong phản hồi.
getUpdates là cách bot liên tục hỏi máy chủ. Webhook thì ngược lại: khi có tin mới, máy chủ Pabal gửi ngay đến địa chỉ HTTPS của bot. Nếu bot chạy trên một máy chủ luôn bật (đám mây, máy chủ nội bộ công ty), webhook là lựa chọn phù hợp hơn.
| getUpdates (long polling) | Webhook | |
|---|---|---|
| Ai bắt đầu | Bot hỏi máy chủ | Máy chủ gửi cho bot |
| Bot cần gì | Chỉ cần kết nối Internet chiều đi ra | Một địa chỉ HTTPS công khai |
| Phù hợp với | Máy tính cá nhân, lúc đang phát triển, sau tường lửa | Máy chủ luôn bật, serverless, nhiều bot |
| Dùng đồng thời | Không được — khi đã thiết lập webhook, getUpdates sẽ trả về 409 Conflict | |
Webhook trao đổi như thế nào
Giải thích sơ đồ
- Hai trục: bên trái là máy chủ Pabal, bên phải là địa chỉ webhook do bạn vận hành. Nét liền màu xanh là request máy chủ gửi đi, màu xám là phản hồi bình thường của webhook, nét đứt màu đỏ là thất bại.
- Phản hồi 2xx nghĩa là "đã nhận" (②). Trước đó, update vẫn nằm lại trên máy chủ. Phần thân phản hồi có thể để trống.
- Nếu thất bại, cùng update đó được gửi lại (④ → ⑤): phản hồi không phải 2xx, lỗi kết nối hay không trả lời trong 30 giây đều tính là thất bại; khoảng cách bắt đầu từ 1 giây và tăng gấp đôi, tối đa 60 giây. Lý do và thời điểm thất bại được ghi lại trong
getWebhookInfo. - Thứ tự được giữ nguyên: update của một bot được gửi từng cái một, nên 7 phải chờ cho đến khi 6 thành công. Vì vậy nếu webhook thất bại lâu, các update phía sau sẽ dồn lại (
pending_update_count). - Có thể trả lời ngay trong phản hồi (⑥): nếu phần thân phản hồi 200 chứa
methodvà các tham số, máy chủ sẽ thay bot thực thi phương thức đó. Không cần gửi thêm một request nữa nên nhanh hơn.
Điều kiện của địa chỉ webhook
- Phải là địa chỉ https://. Chứng chỉ phải do tổ chức cấp chứng chỉ công khai cấp (như Let's Encrypt); không hỗ trợ tải lên chứng chỉ tự ký (
certificate). - Phải là địa chỉ công khai truy cập được từ Internet. Máy chủ không gửi đến các địa chỉ nội bộ như loopback (
127.0.0.1,::1), mạng riêng (10.,172.16–31.,192.168.), link-local (169.254., metadata đám mây), CGNAT (100.64/10). Tên miền trỏ đến những địa chỉ như vậy cũng bị từ chối, và việc kiểm tra được thực hiện lại mỗi lần gửi. - Không giới hạn cổng (không nhất thiết là 443). Không được đưa tên đăng nhập và mật khẩu vào địa chỉ (
https://user:pw@…). - Chuyển hướng (3xx) không được đi theo mà bị tính là thất bại. Hãy dùng địa chỉ cuối cùng.
- Phải phản hồi trong vòng 30 giây. Với việc tốn thời gian, hãy trả lời 200 trước rồi xử lý ở phía sau.
Thiết lập
- Chạy chương trình nhận webhook. Giả sử bạn chạy một trong các ví dụ bên dưới trên máy chủ bot tại
127.0.0.1:8081. - Đặt HTTPS phía trước. Ví dụ với Caddy, chỉ hai dòng này là có luôn cả chứng chỉ tự động.
bot.example.com { reverse_proxy 127.0.0.1:8081 } - Tạo một token bí mật. Dài 1–256 ký tự gồm chữ Latin, chữ số,
_và-. Máy chủ gửi giá trị này trong header của mỗi request, nhờ đó bạn kiểm tra được request có thật sự đến từ máy chủ Pabal hay không.export WEBHOOK_SECRET=$(openssl rand -hex 32) - Gọi setWebhook.
curl -s "https://pabal.me/bot$BOT_TOKEN/setWebhook" -H 'Content-Type: application/json' -d "{ \"url\": \"https://bot.example.com/pabal-webhook\", \"secret_token\": \"$WEBHOOK_SECRET\", \"allowed_updates\": [\"message\", \"callback_query\"], \"drop_pending_updates\": true }" # {"ok":true,"result":true} - Kiểm tra trạng thái. Gửi cho bot một tin nhắn từ ứng dụng rồi xem
getWebhookInfo.curl -s "https://pabal.me/bot$BOT_TOKEN/getWebhookInfo"{ "ok": true, "result": { "url": "https://bot.example.com/pabal-webhook", "has_custom_certificate": false, "pending_update_count": 0, "ip_address": "198.51.100.7", "max_connections": 40, "allowed_updates": ["message", "callback_query"] } }Nếu
pending_update_countlà 0 thì webhook đang nhận tốt.last_error_datevàlast_error_messagelà bản ghi của lần thất bại gần nhất, nên vẫn còn đó kể cả sau khi đã hồi phục — hãy xem thời điểm để đánh giá.
Tham số setWebhook
| Tham số | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
url | String | Có | Địa chỉ webhook. Chuỗi rỗng sẽ xóa webhook (giống deleteWebhook). |
secret_token | String | Tùy chọn | 1–256 ký tự, A-Z a-z 0-9 _ -. Được gửi trong header X-Telegram-Bot-Api-Secret-Token của mỗi request. |
allowed_updates | Array of String | Tùy chọn | Các loại cần nhận: message, edited_message, callback_query. Để trống là nhận tất cả. Loại không có trong danh sách sẽ không được gửi mà bị bỏ đi. |
drop_pending_updates | Boolean | Tùy chọn | Nếu true, bỏ hết các update chưa được chuyển rồi mới bắt đầu. |
max_connections | Integer | Tùy chọn | 1–100, mặc định 40. Được nhận và lưu lại, nhưng để giữ thứ tự, Pabal gửi từng cái một cho mỗi bot. |
certificate | InputFile | Không hỗ trợ | Không nhận chứng chỉ tự ký (400). Hãy dùng chứng chỉ công khai. |
ip_address | String | Bỏ qua | Được nhận nhưng không dùng. Máy chủ phân giải tên miền mỗi lần gửi. |
Thiết lập webhook được lưu trên máy chủ, nên vẫn giữ nguyên khi máy chủ khởi động lại, và việc gửi được tiếp tục ngay khi máy chủ chạy. Tuy nhiên, các update chưa kịp chuyển nằm trong bộ nhớ nên sẽ mất khi khởi động lại.
Request mà máy chủ gửi
POST /pabal-webhook HTTP/1.1
Host: bot.example.com
Content-Type: application/json
X-Telegram-Bot-Api-Secret-Token: 3f1c…(giá trị đã truyền cho setWebhook)
{"update_id":12,"message":{"message_id":3,"from":{"id":100001,"is_bot":false,"first_name":"Mai"},"chat":{"id":100001,"first_name":"Mai","type":"private"},"date":1789805661,"text":"Xin chào"}}
- Phần thân là một đối tượng Update, giống hệt một phần tử trong kết quả
getUpdates. - Hãy luôn kiểm tra token bí mật. Nếu header không có hoặc không khớp thì từ chối bằng 401. So sánh bằng hàm có thời gian không đổi (
hmac.compare_digest,crypto.timingSafeEqual). - Cùng một update có thể đến hai lần (khi kết nối bị ngắt trước lúc phản hồi đến được máy chủ). Bỏ qua những update đã xử lý dựa trên
update_idlà an toàn.
Trả lời ngay trong phản hồi
Nếu phần thân của phản hồi 200 chứa một lời gọi Bot API, máy chủ sẽ thay bot thực thi lời gọi đó. Đặt tên phương thức vào method và đưa các tham số còn lại vào cùng.
{"method": "sendMessage", "chat_id": 100001, "text": "Chào bạn!"}
- Định dạng phần thân có thể là JSON, form hoặc
multipart/form-data(aiogram gửi bằng multipart). Kích thước tối đa 1MB. - Kết quả hay lỗi của lời gọi này không được trả về cho bot (chỉ ghi vào log máy chủ). Nếu cần kết quả, hãy gửi request riêng như bình thường.
- Nếu không có gì để trả lời, chỉ cần trả 200 không có phần thân.
Ví dụ
Cả bốn ví dụ đều kiểm tra token bí mật, và khi nhận được tin nhắn văn bản thì đưa sendMessage vào phản hồi để lặp lại lời người dùng. Giả định rằng HTTPS (ví dụ Caddy) được đặt phía trước và chương trình nhận tại 127.0.0.1:8081.
# webhook.py — chỉ dùng thư viện chuẩn
# Chạy: WEBHOOK_SECRET='…' python3 webhook.py
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"]
class Webhook(BaseHTTPRequestHandler):
def do_POST(self):
got = self.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
if not hmac.compare_digest(got, SECRET):
self.send_response(401)
self.end_headers()
return
update = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
message = update.get("message")
answer = b""
if message and "text" in message:
answer = json.dumps({"method": "sendMessage",
"chat_id": message["chat"]["id"],
"text": message["text"]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
HTTPServer(("127.0.0.1", 8081), Webhook).serve_forever()// webhook.mjs — Node.js 18 trở lên, không cần thư viện
// Chạy: WEBHOOK_SECRET='…' node webhook.mjs
import http from 'node:http';
import { timingSafeEqual } from 'node:crypto';
const SECRET = Buffer.from(process.env.WEBHOOK_SECRET);
http.createServer(async (req, res) => {
const got = Buffer.from(req.headers['x-telegram-bot-api-secret-token'] ?? '');
if (req.method !== 'POST' || got.length !== SECRET.length || !timingSafeEqual(got, SECRET)) {
res.writeHead(401).end();
return;
}
let body = '';
for await (const chunk of req) body += chunk;
const message = JSON.parse(body).message;
if (message?.text) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ method: 'sendMessage', chat_id: message.chat.id, text: message.text }));
} else {
res.writeHead(200).end();
}
}).listen(8081, '127.0.0.1');# webhook_aiogram.py — pip install aiogram
# Chương trình này tự thiết lập webhook (setWebhook) luôn
import os
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web
URL = "https://bot.example.com/pabal-webhook"
SECRET = os.environ["WEBHOOK_SECRET"]
dp = Dispatcher()
@dp.message()
async def echo(message):
if message.text:
return message.answer(message.text) # return: gửi kèm trong phản hồi webhook
async def on_startup(bot: Bot):
await bot.set_webhook(URL, secret_token=SECRET)
def main():
session = AiohttpSession(api=TelegramAPIServer.from_base("https://pabal.me"))
bot = Bot(os.environ["BOT_TOKEN"], session=session)
dp.startup.register(on_startup)
app = web.Application()
SimpleRequestHandler(dispatcher=dp, bot=bot, secret_token=SECRET).register(app, path="/pabal-webhook")
setup_application(app, dp, bot=bot)
web.run_app(app, host="127.0.0.1", port=8081)
main()# webhook_ptb.py — pip install "python-telegram-bot[webhooks]"
# Chương trình này tự thiết lập webhook (setWebhook) luôn
import os
from telegram import Update
from telegram.ext import Application, ContextTypes, MessageHandler, filters
SERVER = "https://pabal.me"
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(update.message.text)
app = (Application.builder().token(os.environ["BOT_TOKEN"])
.base_url(f"{SERVER}/bot").base_file_url(f"{SERVER}/file/bot").build())
app.add_handler(MessageHandler(filters.TEXT, echo))
app.run_webhook(listen="127.0.0.1", port=8081, url_path="pabal-webhook",
webhook_url="https://bot.example.com/pabal-webhook",
secret_token=os.environ["WEBHOOK_SECRET"])Xem trạng thái — getWebhookInfo
| Trường | Ý nghĩa |
|---|---|
url | Địa chỉ đã thiết lập. Chuỗi rỗng nếu chưa có webhook |
pending_update_count | Số update đang chờ vì chưa chuyển được |
ip_address | IP của địa chỉ đã gửi đến lần gần nhất |
last_error_date, last_error_message | Thời điểm (giây Unix) và lý do của lần thất bại gần nhất. Không có nếu chưa từng thất bại. Vẫn còn sau khi đã hồi phục |
max_connections, allowed_updates | Giá trị đã truyền cho setWebhook |
has_custom_certificate | Luôn là false |
Các thông báo trong last_error_message
| Thông báo | Nguyên nhân |
|---|---|
Connection refused | Không có gì đang lắng nghe tại địa chỉ và cổng đó. Chương trình webhook hoặc proxy đang tắt |
Connection timed out | Tường lửa chặn hoặc không truy cập được địa chỉ |
Read timeout expired | Không phản hồi trong vòng 30 giây |
Failed to resolve host: Name or service not known | Không tìm thấy tên miền (DNS) |
SSL error {…} | Vấn đề chứng chỉ — hết hạn, tự ký, sai tên |
Wrong response from the webhook: 502 Bad Gateway | Phản hồi không phải 2xx. Con số là mã trạng thái mà webhook trả về |
IP address 10.0.0.5 is reserved | Tên miền trỏ đến địa chỉ nội bộ (không gửi) |
Quay lại getUpdates
curl -s "https://pabal.me/bot$BOT_TOKEN/deleteWebhook"
# Để bỏ luôn các update đang chờ: deleteWebhook?drop_pending_updates=true
Những update mà webhook đã nhận (đã trả lời 2xx) sẽ không đến lại qua getUpdates. Chỉ những update chưa được chuyển mới tiếp tục được nhận qua getUpdates.
Thử nghiệm trong lúc phát triển
- Tunnel: dùng công cụ gắn địa chỉ HTTPS công khai vào
127.0.0.1:8081trên máy của bạn (cloudflared, ngrok…) thì có thể thử cả với máy chủ Pabal đang vận hành. - Máy chủ Pabal của bạn: nếu bạn tự chạy một máy chủ Pabal để phát triển, hãy bật
TELEGRAM_WEBHOOK_ALLOW_LOCAL=true. Khi đó máy chủ sẽ gửi cả đến địa chỉ cục bộ nhưhttp://127.0.0.1:8081/…. Tuyệt đối không bật trên máy chủ vận hành thật — một con bot bất kỳ sẽ có thể gửi request vào mạng nội bộ của máy chủ (cơ sở dữ liệu, metadata đám mây).
Danh sách kiểm tra khi vận hành
- Đã đặt token bí mật và kiểm tra nó ở mọi request.
- Trả lời trong vòng 30 giây — việc tốn thời gian thì chuyển vào hàng đợi và trả 200 ngay.
- Lọc update trùng bằng
update_id. - Nếu webhook ngừng lâu, các update phía sau sẽ dồn lại và mất khi máy chủ khởi động lại — hãy giám sát chương trình webhook (
pending_update_count,last_error_datecủagetWebhookInfo). - Nếu token bị lộ, dùng
/revokerồi gọi lạisetWebhookvới token mới. Đổi luôn cả token bí mật.