开发者文档
简体中文

机器人开发

Webhook

不用 getUpdates,而是让服务器把新的更新直接发送到机器人的 HTTPS 地址——设置、验证、重试,以及在响应中回复。

getUpdates 是机器人不断向服务器询问的方式。Webhook 则正好相反:一有新的更新,Pabal 服务器就会把它直接发送到机器人的 HTTPS 地址。如果机器人运行在始终开机的服务器(云服务器、公司内部服务器)上,Webhook 会更合适。

getUpdates(长轮询)Webhook
谁先行动机器人向服务器询问服务器向机器人发送
机器人需要什么只要能访问外网公开的 HTTPS 地址
适用场景自己的电脑、开发阶段、防火墙内部始终开机的服务器、Serverless、多个机器人
同时使用不可以——设置了 Webhook 时,getUpdates 会返回 409 Conflict

Webhook 的往来流程

Pabal 服务器 机器人 Webhook ① POST 更新 5 · X-Telegram-Bot-Api-Secret-Token ② 200 OK → 5 已确认,下一个 ③ POST 更新 6 ④ 500 · 连接失败 · 超过 30 秒 → 记录 last_error 间隔 1 秒 → 2 秒 → 4 秒 … 最长 60 秒 重发同一个 6 (7 在 6 之后等待) ⑤ POST 更新 6 (重发) ⑥ 200 + {"method": "sendMessage", "chat_id": …, "text": …} 代替机器人执行响应中的方法 → 回复送到用户的应用 ⑦ POST 更新 7 …
一个机器人的更新一次发送一个,按顺序进行

图示说明

  • 两条主线:左边是 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,再在后台处理。

设置

  1. 启动接收 Webhook 的程序。假设在机器人服务器上以 127.0.0.1:8081 运行下面示例中的某一个。
  2. 在前面加上 HTTPS。例如使用 Caddy 时,只需这两行,连证书也会自动处理。
    bot.example.com {
        reverse_proxy 127.0.0.1:8081
    }
  3. 生成一个密钥令牌。由英文字母、数字、_- 组成,1~256 个字符。服务器会在每个请求的请求头中带上这个值,你可以据此确认请求确实来自 Pabal 服务器。
    export WEBHOOK_SECRET=$(openssl rand -hex 32)
  4. 调用 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}
  5. 检查状态。在应用中给机器人发一条消息,然后查看 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_datelast_error_message最后一次失败的记录,即使恢复之后也会保留——请结合时间来判断。

setWebhook 参数

参数类型必填说明
urlStringWebhook 地址。传入空字符串会删除 Webhook(与 deleteWebhook 相同)。
secret_tokenString可选1~256 个字符,A-Z a-z 0-9 _ -。每个请求都会通过 X-Telegram-Bot-Api-Secret-Token 请求头发送。
allowed_updatesArray of String可选要接收的类型:messageedited_messagecallback_query。留空表示全部。未列出的类型不会发送,而是直接丢弃。
drop_pending_updatesBoolean可选为 true 时,先丢弃所有尚未送达的更新再开始。
max_connectionsInteger可选1~100,默认 40。会接收并保存,但为了保证顺序,Pabal 对每个机器人一次只发送一个
certificateInputFile不支持不接受自签名证书(400)。请使用公共证书。
ip_addressString忽略会接收但不使用。服务器每次都会实时解析域名。

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_digestcrypto.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 expired30 秒内没有响应
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 程序(getWebhookInfopending_update_countlast_error_date)。
  • 令牌泄露时,先 /revoke,再用新令牌重新调用 setWebhook。密钥令牌也一并更换。