ボット開発
Webhook
getUpdates の代わりに、サーバーから新着をボットの HTTPS アドレスへ直接送らせる方法 — 設定、検証、再試行、レスポンスでの返信。
getUpdates は、ボットがサーバーに問い合わせ続ける方式です。Webhook はその逆で、新着が発生すると Pabal サーバーがボットの HTTPS アドレスに直接送る方式です。ボットが常時稼働しているサーバー(クラウド・社内サーバー)で動くなら、Webhook のほうが適しています。
| getUpdates(ロングポーリング) | Webhook | |
|---|---|---|
| どちらから | ボットがサーバーに問い合わせる | サーバーがボットに送る |
| ボットに必要なもの | 外向きのインターネット接続だけ | 公開された HTTPS アドレス |
| 向いている環境 | 自分のコンピューター、開発中、ファイアウォールの内側 | 常時稼働のサーバー、サーバーレス、複数のボット |
| 同時利用 | 不可 — Webhook が設定されていると、getUpdates は 409 Conflict | |
Webhook のやり取り
図の説明
- 2 本の縦線:左が Pabal サーバー、右があなたが運用する Webhook のアドレスです。青い実線はサーバーが送るリクエスト、灰色は Webhook の正常なレスポンス、赤い点線は失敗です。
- 2xx のレスポンスが「受信済み」の合図です(②)。それまで、アップデートはサーバーに残っています。本文は空でもかまいません。
- 失敗すると、同じアップデートを送り直します(④ → ⑤):2xx 以外のレスポンス、接続の失敗、30 秒以内に応答がない場合はいずれも失敗で、間隔は 1 秒から 2 倍ずつ伸び、最大 60 秒です。失敗の理由と時刻は
getWebhookInfoに残ります。 - 順序は守られます:1 つのボットのアップデートは一度に 1 つずつ送るため、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 なら、この 2 行だけで証明書の取得まで自動で行われます。
bot.example.com { reverse_proxy 127.0.0.1:8081 } - シークレットトークンを 1 つ作ります。英字・数字・
_・-で 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 は順序を守るため、ボットごとに一度に 1 つずつ送ります。 |
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":"やあ"}}
- 本文は、
getUpdatesの結果の要素 1 つとまったく同じ Update オブジェクトです。 - シークレットトークンを必ず確認してください。ヘッダーがないか値が異なる場合は、401 で拒否します。比較には、処理時間が一定の関数(
hmac.compare_digest、crypto.timingSafeEqual)を使います。 - 同じアップデートが 2 回届くことがあります(レスポンスがサーバーに届く前に接続が切れた場合)。
update_idで処理済みのものを読み飛ばせば安全です。
レスポンスで返信する
200 レスポンスの本文に Bot API の呼び出しを 1 つ入れると、サーバーがその呼び出しをボットの代わりに実行します。method にメソッド名を入れ、残りのパラメーターも一緒に入れます。
{"method": "sendMessage", "chat_id": 100001, "text": "こんにちは!"}
- 本文の形式は、JSON、フォーム、
multipart/form-dataのいずれも使えます(aiogram は multipart で送ります)。サイズは 1MB までです。 - この呼び出しの結果やエラーはボットには返りません(サーバーのログにだけ残ります)。結果が必要なら、通常どおり別途リクエストしてください。
- 返信することがなければ、本文なしで 200 だけを返せば十分です。
例
4 つの例はいずれもシークレットトークンを確認し、テキストメッセージを受け取るとレスポンスに 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/…のようなローカルアドレスにも送ります。本番サーバーでは絶対に有効にしないでください — 1 つのボットが、サーバーの内部ネットワーク(データベース、クラウドのメタデータ)にリクエストを送れるようになってしまいます。
運用チェックリスト
- シークレットトークンを決め、すべてのリクエストで確認している。
- 30 秒以内に応答する — 時間のかかる処理はキューに回し、すぐに 200 を返す。
update_idで重複を取り除く。- Webhook が長く止まると後続のアップデートがたまり、サーバーの再起動時に消える — Webhook のプログラムを監視する(
getWebhookInfoのpending_update_count、last_error_date)。 - トークンが漏れたら、
/revokeのあと新しいトークンでsetWebhookを呼び直す。シークレットトークンも一緒に変える。