Claude Media
Claude CodeでTelegram Botを作る — webhookとトークン権限の設計

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が動きません。

観点getUpdatessetWebhook
仕組みgetUpdatesBot側がAPIに取りに行くsetWebhookTelegramがBotのURLへHTTPS POSTする
公開URLgetUpdates不要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ルールが届かない範囲(サブプロセスやパスを指定しない検索)を知ったうえで、サンドボックスとフックで補う構成が現実的です。

この記事を共有:XはてブLinkedIn