机器人开发
Webhook
不用 getUpdates,而是让服务器把新的更新直接发送到机器人的 HTTPS 地址——设置、验证、重试,以及在响应中回复。
getUpdates 是机器人不断向服务器询问的方式。Webhook 则正好相反:一有新的更新,Pabal 服务器就会把它直接发送到机器人的 HTTPS 地址。如果机器人运行在始终开机的服务器(云服务器、公司内部服务器)上,Webhook 会更合适。
| getUpdates(长轮询) | Webhook | |
|---|---|---|
| 谁先行动 | 机器人向服务器询问 | 服务器向机器人发送 |
| 机器人需要什么 | 只要能访问外网 | 公开的 HTTPS 地址 |
| 适用场景 | 自己的电脑、开发阶段、防火墙内部 | 始终开机的服务器、Serverless、多个机器人 |
| 同时使用 | 不可以——设置了 Webhook 时,getUpdates 会返回 409 Conflict | |
Webhook 的往来流程
图示说明
- 两条主线:左边是 Pabal 服务器,右边是你运营的 Webhook 地址。蓝色实线是服务器发出的请求,灰色是 Webhook 的正常响应,红色虚线表示失败。
- 2xx 响应就代表“已收到”(②)。在此之前,更新会一直保留在服务器上。响应正文可以为空。
- 失败时会重发同一个更新(④ → ⑤):非 2xx 的响应、连接失败、30 秒内没有应答都算失败,重发间隔从 1 秒开始逐次翻倍,最长 60 秒。失败原因和时间会记录在
getWebhookInfo中。 - 顺序得到保证:一个机器人的更新一次只发送一个,所以在 6 成功之前,7 会一直等待。因此如果 Webhook 长时间失败,后面的更新就会堆积起来(
pending_update_count)。 - 可以在响应中回复(⑥):在 200 响应的正文中放入
method和参数,服务器就会代替机器人执行该方法。不需要再另外发送一次请求,所以更快。
Webhook 地址的条件
- 必须是 https:// 地址。证书必须由公共证书颁发机构(Let's Encrypt 等)签发,不支持上传自签名证书(
certificate)。 - 必须是可以从互联网访问的公网地址。服务器不会向回环地址(
127.0.0.1、::1)、私有网络(10.、172.16–31.、192.168.)、链路本地地址(169.254.,云元数据)、CGNAT(100.64/10)等内部地址发送。即使是域名指向这类地址也会拒绝,而且每次发送前都会重新检查。 - 端口没有限制(不是 443 也可以)。地址中不能包含用户名和密码(
https://user:pw@…)。 - 不跟随重定向(3xx),而是视为失败。请填写最终地址。
- 必须在 30 秒内响应。耗时较长的工作,请先返回 200,再在后台处理。
设置
- 启动接收 Webhook 的程序。假设在机器人服务器上以
127.0.0.1:8081运行下面示例中的某一个。 - 在前面加上 HTTPS。例如使用 Caddy 时,只需这两行,连证书也会自动处理。
bot.example.com { reverse_proxy 127.0.0.1:8081 } - 生成一个密钥令牌。由英文字母、数字、
_、-组成,1~256 个字符。服务器会在每个请求的请求头中带上这个值,你可以据此确认请求确实来自 Pabal 服务器。export WEBHOOK_SECRET=$(openssl rand -hex 32) - 调用 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} - 检查状态。在应用中给机器人发一条消息,然后查看
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"] } }pending_update_count为 0 就说明接收正常。last_error_date和last_error_message是最后一次失败的记录,即使恢复之后也会保留——请结合时间来判断。
setWebhook 参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
url | String | 是 | Webhook 地址。传入空字符串会删除 Webhook(与 deleteWebhook 相同)。 |
secret_token | String | 可选 | 1~256 个字符,A-Z a-z 0-9 _ -。每个请求都会通过 X-Telegram-Bot-Api-Secret-Token 请求头发送。 |
allowed_updates | Array of String | 可选 | 要接收的类型:message、edited_message、callback_query。留空表示全部。未列出的类型不会发送,而是直接丢弃。 |
drop_pending_updates | Boolean | 可选 | 为 true 时,先丢弃所有尚未送达的更新再开始。 |
max_connections | Integer | 可选 | 1~100,默认 40。会接收并保存,但为了保证顺序,Pabal 对每个机器人一次只发送一个。 |
certificate | InputFile | 不支持 | 不接受自签名证书(400)。请使用公共证书。 |
ip_address | String | 忽略 | 会接收但不使用。服务器每次都会实时解析域名。 |
Webhook 设置保存在服务器上,服务器重启后依然有效,启动后会立即重新开始发送。不过尚未送达的更新保存在内存中,重启时会丢失。
服务器发送的请求
POST /pabal-webhook HTTP/1.1
Host: bot.example.com
Content-Type: application/json
X-Telegram-Bot-Api-Secret-Token: 3f1c…(在 setWebhook 中设置的值)
{"update_id":12,"message":{"message_id":3,"from":{"id":100001,"is_bot":false,"first_name":"小明"},"chat":{"id":100001,"first_name":"小明","type":"private"},"date":1789805661,"text":"嗨"}}
- 正文是一个 Update 对象,与
getUpdates结果中的单个元素完全相同。 - 请务必检查密钥令牌。请求头缺失或值不一致时,以 401 拒绝。比较时请使用耗时恒定的函数(
hmac.compare_digest、crypto.timingSafeEqual)。 - 同一个更新可能会收到两次(响应到达服务器之前连接断开的情况)。用
update_id跳过已经处理过的更新就很安全。
在响应中回复
在 200 响应的正文中放入一次 Bot API 调用,服务器就会代替机器人执行这个调用。把方法名放在 method 中,并把其余参数一起放进去。
{"method": "sendMessage", "chat_id": 100001, "text": "你好!"}
- 正文格式可以是 JSON、表单或
multipart/form-data(aiogram 使用 multipart 发送)。大小上限为 1MB。 - 这次调用的结果或错误不会返回给机器人(只记录在服务器日志中)。如果需要结果,请像平时一样另外发送请求。
- 如果没有需要回复的内容,只返回不带正文的 200 即可。
示例
四个示例都会检查密钥令牌,收到文字消息时在响应中放入 sendMessage,把原话复述回去。这里假设前面有 HTTPS(例如 Caddy),并在 127.0.0.1:8081 上接收。
# webhook.py — 只用标准库
# 运行: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 及以上,不使用库
// 运行: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
# 连 Webhook 设置(setWebhook)也由这个程序完成
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:放进 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]"
# 连 Webhook 设置(setWebhook)也由这个程序完成
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"])查看状态——getWebhookInfo
| 字段 | 含义 |
|---|---|
url | 已设置的地址。没有 Webhook 时为空字符串 |
pending_update_count | 尚未送达、正在等待的更新数量 |
ip_address | 最近一次发送时目标地址的 IP |
last_error_date, last_error_message | 最后一次失败的时间(Unix 秒)和原因。从未失败过则没有这两个字段。恢复后仍会保留 |
max_connections, allowed_updates | 在 setWebhook 中传入的值 |
has_custom_certificate | 始终为 false |
last_error_message 中出现的信息
| 信息 | 原因 |
|---|---|
Connection refused | 该地址和端口上没有任何程序在监听。Webhook 程序或代理没有运行 |
Connection timed out | 被防火墙拦截,或者地址无法到达 |
Read timeout expired | 30 秒内没有响应 |
Failed to resolve host: Name or service not known | 找不到该域名(DNS) |
SSL error {…} | 证书问题——已过期、自签名、名称不匹配 |
Wrong response from the webhook: 502 Bad Gateway | 非 2xx 的响应。数字是 Webhook 返回的状态码 |
IP address 10.0.0.5 is reserved | 域名指向内部地址(不会发送) |
回到 getUpdates
curl -s "https://pabal.me/bot$BOT_TOKEN/deleteWebhook"
# 如果要连等待中的更新也一并丢弃:deleteWebhook?drop_pending_updates=true
已经被 Webhook 接收(以 2xx 应答)的更新,不会再通过 getUpdates 重复送达。只有尚未送达的更新,会接着通过 getUpdates 接收。
开发时进行测试
- 隧道:使用为自己电脑上的
127.0.0.1:8081提供公网 HTTPS 地址的工具(cloudflared、ngrok 等),就能用正式运营的 Pabal 服务器进行测试。 - 自己的 Pabal 服务器:如果你自己启动了开发用的 Pabal 服务器,请用
TELEGRAM_WEBHOOK_ALLOW_LOCAL=true开启。这样该服务器也会向http://127.0.0.1:8081/…之类的本地地址发送。绝对不要在正式运营的服务器上开启——否则任何一个机器人都能向服务器的内部网络(数据库、云元数据)发送请求。
运营检查清单
- 已设定密钥令牌,并在每个请求中进行检查。
- 在 30 秒内应答——耗时的工作交给队列,立即返回 200。
- 用
update_id过滤重复的更新。 - Webhook 长时间停止时,后面的更新会堆积,并在服务器重启时丢失——请监控 Webhook 程序(
getWebhookInfo的pending_update_count、last_error_date)。 - 令牌泄露时,先
/revoke,再用新令牌重新调用setWebhook。密钥令牌也一并更换。