Claude CodeでTelegram Botを作る — webhookとトークン権限の設計
Telegram Botのwebhook受信をClaude Codeに書かせつつ、Botトークンを.envに置いてdenyルールで読ませない設定まで、手順とコードで示します。
Telegram Botの実装は、Claude Codeに任せやすい題材です。Bot APIはHTTPSとJSONだけで完結し、受信の方式も2通りしかありません。ただし一点、他のBotより神経を使う場所があります。Botトークンです。
トークンはhttps://api.telegram.org/bot<token>/METHOD_NAMEのURLにそのまま入ります。.envをClaudeに読ませてしまうと、会話の履歴とコマンド出力にトークンが流れ込みます。この記事は、webhook受信サーバーの実装をClaude Codeに書かせながら、トークンを読ませない権限設計を先に固める流れで進めます。
Telegram Botの受信方式は2通りで、同時には使えない
Bot APIには、更新(メッセージなどのイベント)を受け取る方法が2つあります。getUpdates(ロングポーリング)とwebhookです。この2つは排他で、webhookを設定している間はgetUpdatesが動きません。
| 観点 | getUpdates | setWebhook |
|---|---|---|
| 仕組み | getUpdatesBot側がAPIに取りに行く | setWebhookTelegramがBotのURLへHTTPS POSTする |
| 公開URL | getUpdates不要 | setWebhookHTTPSのURLが必要 |
| 向く場面 | getUpdatesローカルでの動作確認 | setWebhook常時稼働の本番 |
| 未受信の更新の保持 | getUpdates最長24時間 | setWebhook最長24時間 |
未受信の更新は、どちらの方式でもサーバー側に最長24時間残ります。
webhookで使えるポートは443、80、88、8443の4つです。自己署名証明書を使う場合は、公開鍵証明書をcertificateパラメータにファイルとして渡します。文字列では通りません。
開発中はローカルに公開URLがありません。そこで、手元ではgetUpdatesで動作を確かめ、本番でwebhookに切り替える二段構えが素直です。切り替えるときはdeleteWebhookかsetWebhookのどちらかを明示的に呼びます。
トークンを読ませない設定を、コードより先に入れる
順序が大事です。実装を頼む前に、.claude/settings.jsonにdenyルールを入れます。
mkdir -p telegram-bot && cd telegram-bot
git init
printf 'BOT_TOKEN=\nWEBHOOK_SECRET=\n' > .env.example
cp .env.example .env
printf '.env\n.env.*\n!.env.example\nnode_modules/\n' > .gitignore.envの中身は自分のエディタで書き込みます。Claudeには書かせません。次にプロジェクト設定です。
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(!.env.example)",
"Bash(curl *)",
"Bash(wget *)"
]
}
}各行の役割は次のとおりです。
Read(./.env)とRead(./.env.*):.env系のファイルをClaudeの読み取りから外します。公式の説明では、一致するファイルはファイル検索の結果からも除かれ、EditとWriteのツールもそのパスでブロックされますRead(!.env.example):!で始まるdenyは否定です。同じ設定ファイルの前に並べたルールから、.env.exampleだけを除外します。雛形はClaudeに読ませて、変数名を把握させられますBash(curl *)とBash(wget *): トークン入りのURLをClaudeがBash経由で叩く経路を狭めます
評価の順序はdeny、ask、allowです。denyが最初に一致したら、それが結果になります。allowルールでdenyの例外を作ることはできません。
denyルールが止められないもの
ここは過信しないでください。公式ドキュメントは、次の限界を明記しています。
- ReadとEditのdenyは、組み込みのファイルツールと、Claude Codeが認識する
cat・head・tail・sed・teeなどのBashコマンドに効きます - ファイル名を指定せずに読むコマンド(例えばそのディレクトリで
grep -r pattern .)には効きません - PythonやNodeのスクリプトが自分でファイルを開く場合も対象外です
Bash(curl *)は、コマンドが書かれたとおりの形にしか一致しません。/usr/bin/curlやsh -c 'curl ...'は止まりません
.envを読むのは、Botを起動した後のNodeプロセスです。Claudeがnode server.jsを実行すれば、プロセスは.envを読みます。そのプロセスの標準出力にトークンを出さなければ、Claudeには渡りません。ログ出力にトークンやリクエストURLを含めない、というコードの規律が要ります。
OSレベルで塞ぎたいなら、サンドボックスを有効にします。サンドボックスはBash・PowerShell・Monitorのコマンドとその子プロセスに対して、ファイルとネットワークのアクセスを制限します。認証情報ファイルを隠す設定はsandbox.credentials.filesの解説にまとめています。
コマンドの全文を自分のロジックで検査したいときは、PreToolUseフックを使います。フックがexit code 2で終わればツール呼び出しは止まります。書き方はHooksガイドを参照してください。
webhook受信サーバーをClaude Codeに書かせる
権限が固まったら、実装を依頼します。プロンプトには制約を書き込みます。
Telegram Botのwebhook受信サーバーをNode.jsとExpressで書いて。
- 環境変数はBOT_TOKENとWEBHOOK_SECRET。.envは読めないので.env.exampleを参照
- リクエストのX-Telegram-Bot-Api-Secret-Tokenヘッダを検証し、不一致なら401
- 受け取ったらすぐ200を返し、処理は非同期で続ける
- テキストメッセージには同じ文を返信、/startにはあいさつを返す
- ログにトークンやAPIのURLを出さない出力は毎回変わります。次は、この指示から期待する実装の一例です(公式のパラメータ名に沿った形)。
import "dotenv/config";
import express from "express";
const { BOT_TOKEN, WEBHOOK_SECRET, PORT = 3000 } = process.env;
const api = `https://api.telegram.org/bot${BOT_TOKEN}`;
async function sendMessage(chatId, text) {
const res = await fetch(`${api}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text }),
});
const body = await res.json();
if (!body.ok) console.error("sendMessage failed:", body.description);
}
async function handleUpdate(update) {
const msg = update.message;
if (!msg?.text) return;
const reply = msg.text.startsWith("/start")
? "こんにちは。メッセージを送ると同じ文を返します。"
: msg.text;
await sendMessage(msg.chat.id, reply);
}
const app = express();
app.use(express.json());
app.post("/webhook", (req, res) => {
if (req.get("X-Telegram-Bot-Api-Secret-Token") !== WEBHOOK_SECRET) {
return res.sendStatus(401);
}
res.sendStatus(200);
handleUpdate(req.body).catch((e) => console.error(e.message));
});
app.listen(PORT);ポイントは3つあります。
先に200を返す。webhookの応答が2XX以外だと、Telegramは同じ更新を再送し、何度か試して諦めます。重い処理を待ってからレスポンスを返すと、タイムアウトで再送を招きます。
ヘッダ検証は最初に行う。 setWebhookのsecret_tokenを指定すると、以後のリクエストすべてにX-Telegram-Bot-Api-Secret-Tokenヘッダが付きます。設定した自分のwebhookからの通信だと確かめる用途です。
エラーログにURLを出さない。ライブラリによっては、失敗したリクエストのURLをエラーメッセージに含めます。bot<token>がURLパスに入っているので、そのまま出力するとトークンが流出します。上のコードはe.messageだけを出す形にしています。
webhookの登録は人間が実行する
setWebhookの呼び出しにはトークンが要ります。ここをClaudeに任せると、トークンを渡すことになります。次のスクリプトなら、変数を展開するのは自分のシェルだけです。
set -a; source .env; set +a
curl -s "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \
-d "url=https://bot.example.com/webhook" \
-d "secret_token=${WEBHOOK_SECRET}" \
-d 'allowed_updates=["message"]'urlにはHTTPSのURLを渡します。secret_tokenは1〜256文字で、使える文字はA-Z、a-z、0-9、_、-だけです。openssl rand -hex 32のような出力がそのまま使えます。
allowed_updatesは、受け取る更新の種類を絞るパラメータです。空のリストを渡すと、chat_member・message_reaction・message_reaction_countを除く全種類を受け取ります(これが既定)。指定しなければ前回の設定が引き継がれます。この設定は、指定より前に作られた更新には効かないため、しばらく想定外の種類が届くことがあります。
登録できたかはgetWebhookInfoで確認します。urlが空なら、そのBotはgetUpdatesを使っています。
curl -s "https://api.telegram.org/bot${BOT_TOKEN}/getWebhookInfo"Bash(curl *)をdenyに入れているので、Claude自身にこれらのコマンドは実行させられません。実行は自分の端末です。Claudeには「登録スクリプトの中身を書く」ところまでを任せ、実行の手前で止めます。
ローカル確認はgetUpdatesで回す
webhookの公開URLがない手元では、getUpdatesのロングポーリングで確認します。このとき、過去に設定したwebhookが残っているとgetUpdatesは動きません。deleteWebhookを先に呼びます。
getUpdatesを使うコードをClaudeに書かせるなら、次の点をプロンプトに含めます。
timeoutは正の値にする(既定の0は短いポーリングで、公式はテスト用途に限ると書いています)offsetは、直近のupdate_idに1を足した値にする。そうしないと同じ更新が重複して届くlimitは1〜100で、既定は100
offsetの扱いは、コードの外から見て気づきにくい部分です。getUpdatesは、そのoffsetより古い更新を確認済みとみなして忘れます。ループの最後に更新する書き方をしてもらうと、再起動後の重複も減ります。
トークンを漏らしたときと、送信レートの注意
トークンがチャットや履歴に出てしまった場合は、新しいトークンを発行し直します。発行方法はBot APIドキュメント冒頭の「Authorizing your bot」から案内されています。発行し直したら、.envの値も入れ替えます。
送信のレートにも上限があります。既定では、Botは1秒あたり最大30メッセージまで一斉送信できます。BotFatherで有料の一斉送信を有効にすると、1秒1,000メッセージまで上がります。無料枠を超えた分は、1メッセージあたり0.1 Starsかかり、有効化にはBotの残高が10,000 Stars以上必要です。
一度のsendMessageのテキストは、エンティティ解析後で1〜4096文字です。Claudeが長い要約を返す設計にするなら、分割送信の処理を頼んでおくと安全です。
よくあるつまずき
- webhookを設定したら
getUpdatesが空になる。排他の仕様どおりです。getWebhookInfoでurlを確認し、戻すならdeleteWebhookを呼びます - 同じメッセージが何度も届く。webhookの応答が2XXになっていません。処理の完了を待たずに200を返します
- 401が続く。
setWebhookに渡したsecret_tokenと、サーバー側の環境変数が食い違っています。ヘッダ名はX-Telegram-Bot-Api-Secret-Tokenです - denyを入れたのにClaudeが
.envの値を知っている。Nodeスクリプトなどのサブプロセスは、Readのdenyの対象外です。標準出力にトークンを出していないか確認し、必要ならサンドボックスを有効にします - ローカルからwebhookを試したい。使えるポートは443・80・88・8443です。自己署名証明書は、公開鍵を
certificateでアップロードします
Webhook受信とSDKでの応答という同じ構成は、LINE Botの作り方でも扱っています。LINEは署名検証、Telegramは共有シークレットのヘッダと、検証の仕組みが違う点に注目すると整理しやすくなります。
まとめ
Telegram Botの実装は、受信方式(getUpdatesかwebhookか)の選択、webhookのヘッダ検証、先に200を返す設計の3点を押さえれば、Claude Codeに任せられます。トークンについては、コードを書かせる前にdenyルールを入れ、.envの中身とwebhook登録の実行を自分の手元に残します。denyルールが届かない範囲(サブプロセスやパスを指定しない検索)を知ったうえで、サンドボックスとフックで補う構成が現実的です。