telegram-bot-ui について
telegram-bot-uiは、Telegramボットのキーボード構築と操作処理を扱うスキルです。ボタン、コマンド、ForceReplyの使い分けを説明し、メニュー、確認、ページ送り用のビルダーを紹介します。
主な機能
- 01インライン・返信キーボードの解説
- 02ボタンとテキスト入力の使い分け
- 03メニューと確認キーボードの構築
- 04ページ送り用キーボードの作成
- 05URL・コピー・Web Appボタンに対応
- 06grammYのコールバック処理を解説
活用シーン
- 01ボタン式メインメニューの追加
- 02ForceReplyによる自由入力の収集
- 03確認・ページ送りフローの構築
- 04コピー可能なテキストやMini Appボタンの追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agntdev/ skills --skill telegram-bot-uiインストール後、Claude Code で/telegram-bot-uiを入力して読み込まれたか確認します。
同梱ファイルreferences/grammY-keyboards.md
詳細
telegram-bot-ui Skill
Telegram キーボードの接続方法――仕組みだけを扱います。ボットが何を話し、どんな雰囲気にするかについては、 telegram-bot-ux をご覧ください。
⭐ ボタン優先 — 機能にアクセスするための基本方針
これらのボットを開発・提供するオーナーは技術者ではなく、ユーザーも同様です。ユーザーはスラッシュコマンドを覚えるのではなく、ボタンをタップしてボットを操作します。そのため、機能へアクセスできるようにするには、
bot.command(...)を新たに作るのではなく、/startのメインメニューにボタンを追加します。bot-starter テンプレートでは、これは 1 行でできます。
src/handlers/<slug>.tsの先頭でregisterMainMenuItem({ label, data })を呼び出し、.callbackQuery(...)でそのdataを処理します。提供されている/startが、項目を集約したメニューを表示します。ボットが公開するスラッシュコマンドは、/start、/help、そして必要に応じて下記の自由入力だけにしてください。機能ごとにスラッシュコマンドを追加してはいけません。そうすると、人が使いこなせない、20 個以上の難解で重複したコマンドを抱えるボットになってしまいます。これは「コマンドを一切使わない」という意味ではありません。ボタンとコマンドの使い分けのヒューリスティックをご覧ください。検索クエリ、メモ、住所、日付、時刻、金額など、ボットが予測できない自由入力には、引き続きコマンドと ForceReply が適しています。ナビゲーションと選択にはボタン、ボットが予測できない入力にはコマンドを使います。
agntdev パイプライン向けに作られています。 詳しくは、 agnt-cli-builder の 発見とタスク獲得のループをご覧ください。このスキルでは、 獲得したタスクの実装で使うインラインボタン、メニュー、 ページネーション、confirmKeyboard、ForceReply、copy_text のパターンを説明します。
1. Telegram キーボードの仕組み(Bot API)
Telegram には2 種類のキーボードと、特殊な入力向けの複数のマークアップ形式があります。用途によって JSON の形も異なります。
InlineKeyboardMarkup
特定のメッセージに付けるボタンです。タップすると、チャットにはメッセージを送らずに、callback_query がボットに送られます。メニュー、確認、ページネーションに適しています。
// Attached to sendMessage reply_markup field
{
"inline_keyboard": [
[
{ "text": "Yes", "callback_data": "confirm:42:yes" },
{ "text": "No", "callback_data": "confirm:42:no" }
],
[
{ "text": "Open Site", "url": "https://example.com" }
]
]
}
「Yes」をタップすると、ボットは data: "confirm:42:yes" を含む callback_query を受け取ります。
制限事項(telegram-bot-api-fundamentals §4 を参照):
callback_dataは 64 バイト以下(UTF-8)。キリル文字や絵文字は複数バイトです。- 1 つのキーボードにつきボタンは 100 個以下です。
- 行数は 8 行以下です(iOS では約 5 行でスクロールします)。
- ボタンのテキストは約 64 文字以下です(超えると切り詰められます。モバイルでは 24 文字以下にしてください)。
ReplyKeyboardMarkup
ユーザーのキーボードを置き換える、常時表示されるボタンです。タップすると通常のテキストメッセージが送信されます。常設メニューやクイック返信に適しています。
{
"keyboard": [
[{ "text": "📅 Book" }, { "text": "📋 My bookings" }],
[{ "text": "❌ Cancel" }]
],
"resize_keyboard": true,
"one_time_keyboard": false,
"input_field_placeholder": "Type or tap…",
"selective": false
}
便利なパラメーター:
resize_keyboard— ボタンを小さくします。Telegram Desktop 5.3.2 以降では無視されます(常に 1 ボタンあたり 54px のフラットグリッドを使用し、横幅は 530px が上限です)。one_time_keyboard: true— 最初のタップ後にキーボードを非表示にします。一度だけ表示するプロンプトに適しています。input_field_placeholder— キーボードの上にある入力欄に表示するテキストです。ユーザーにヒントが表示されるため、使ってください。selective: true— グループ内の特定ユーザーにだけ、このキーボードを表示します(まれなケースです)。
ForceReply
チャット内で返信するようユーザーに促します(インラインの「返信先」UI が表示されます)。ボタンでは受け取れない、必須のテキスト入力に使用してください。
{
"force_reply": true,
"input_field_placeholder": "Send your booking address…",
"selective": false
}
ユーザーには入力欄の上に「Replying to your bot…」というラベルが表示されます。ボット側では、次のテキストメッセージを明示的に処理する必要があります(セッションのステップでフィルタリングします。詳しくは telegram-bot-ux §6 のリニアウィザードをご覧ください)。
ボタンとコマンド — 判断の目安
| ユーザーが行いたいこと | 使用するもの |
|---|---|
| 少数の固定された選択肢(はい/いいえ、選択肢 A/B/C、ページ N)から選ぶ | インラインボタン |
| メニューを開く、操作を確認する、ページを移動する | インラインボタン |
| 自由形式のテキストを送信する(コントラクトアドレス、メモ、検索クエリ) | /command +テキスト入力 |
| ボットが解析する構造化引数(日時、時刻、金額)を入力する | /command +テキスト入力 |
| 必須のテキスト返信(ボットの質問に回答する) | ForceReply |
| ユーザーが繰り返しタップしたい固定ショートカット | 返信キーボード(控えめに使用) |
「インラインボタンをデフォルトにする」という以前のルールは誤りです。選択肢にはボタンが適しており、自由入力にはコマンドが適しています。あらゆる入力をボタンに変えるボットは、パワーユーザーにとって使いにくいものです。メニューにコマンドを使うボットは、モバイルユーザーにとって使いにくいものです。
迷ったときは、こう考えてください。「ユーザーは何を入力すればよいか分かっていますか?」分かっているなら、コマンドを使います。分からない場合(または有効な選択肢が多すぎて覚えられない場合)は、ボタンを使います。
ボタンに表示する文言(動詞を先頭に置く、文頭のみ大文字にする、絵文字の使用量)については、telegram-bot-ux §1 マイクロコピーを参照してください。
編集と送信
# Edit existing message (for inline keyboards — user doesn't see new messages)
POST /bot<TOKEN>/editMessageText { chat_id, message_id, text, reply_markup }
# Edit only the keyboard (keep text)
POST /bot<TOKEN>/editMessageReplyMarkup { chat_id, message_id, reply_markup }
# Send new message (for reply keyboard flows)
POST /bot<TOKEN>/sendMessage { chat_id, text, reply_markup }
2. grammY — キーボードの使用
キーボードの添付、コールバックの処理、メッセージの編集と新規送信の UX パターンに関する grammY の仕組みは、references/grammY-keyboards.md に記載されています。インライン/返信/ForceReply/連絡先リクエスト/位置情報リクエスト/ユーザーリクエスト/チャットリクエスト/管理対象ボットリクエストの正確な reply_markup の形式、コールバックデータのプレフィックスによるルーティング規則、Telegram のメッセージ内編集に関する UX の注意点が必要な場合は、このファイルを読んでください。
3. ツールキット(src/toolkit/)— UI ビルダー
インライン化されたツールキット(bot-starter テンプレートの src/toolkit/)は、プレーンな InlineKeyboardMarkup オブジェクトを返す純粋なビルダーを提供します。grammY のインポートは不要です。grammY が期待する JSON 形式をそのまま生成します。
import {
inlineButton, urlButton, copyTextButton, webAppButton,
inlineKeyboard, menuKeyboard, confirmKeyboard, paginate,
} from "../src/toolkit/ui/buttons.js";
inlineButton(text, callbackData)
inlineButton("Yes", "confirm:42")
// → { text: "Yes", callback_data: "confirm:42" }
// Type: { text: string; callback_data: string }
urlButton(text, url)
urlButton("Docs", "https://agnt-gm.ai")
// → { text: "Docs", url: "https://agnt-gm.ai" }
// Type: { text: string; url: string }
copyTextButton(text, copyText) — ワンタップでコピー
copyTextButton("📋 Copy order ID", "ORD-12345")
// → { text: "📋 Copy order ID", copy_text: { text: "ORD-12345" } }
// Type: { text: string; copy_text: { text: string } }
ID、アドレス、コード、リンクなど、ユーザーが通常は長押ししてコピーするものに使用します。操作ごとにタップを 4 回節約できます。telegram-bot-ux §10 のアンチパターン「ID に copy_text を使わない」も参照してください。
webAppButton(text, url) — ミニアプリを開く
webAppButton("🛒 Open shop", "https://shop.example.com/twa")
// → { text: "🛒 Open shop", web_app: { url: "https://shop.example.com/twa" } }
// Type: { text: string; web_app: { url: string } }
Bot API 9.4 以降に対応しています。フローがインラインキーボードで対応できる範囲を超えた場合に使用します。明確な 4 つの判断基準については、telegram-bot-ux §8 ミニアプリへの移行を参照してください。
inlineKeyboard(rows)
ボタンの行を有効な InlineKeyboardMarkup にまとめます。
const kb = inlineKeyboard([
[inlineButton("A", "a"), inlineButton("B", "b")],
[urlButton("Docs", "https://x.io")],
]);
await ctx.reply("Choose:", { reply_markup: kb });
menuKeyboard(items, columns?)
フラットなリストからグリッドレイアウトを作成します。デフォルトは 1 列です。
const items = [
{ text: "📅 Book", data: "menu:book" },
{ text: "📋 My bookings", data: "menu:my" },
{ text: "❌ Cancel", data: "menu:cancel" },
{ text: "ℹ️ Help", data: "menu:help" },
];
menuKeyboard(items); // vertical list, 1 per row
menuKeyboard(items, 2); // 2-column grid
confirmKeyboard(actionPrefix, opts?)
はい/いいえの行です。コールバックは <prefix>:yes / <prefix>:no です。
confirmKeyboard("delete:42");
// → [✅ Yes ("delete:42:yes")] [❌ No ("delete:42:no")]
confirmKeyboard("publish", { yes: "🚀 Publish", no: "🔙 Back" });
ハンドラー:
bot.on("callback_query:data", async (ctx) => {
const data = ctx.callbackQuery.data;
if (data.startsWith("delete:")) {
const [, id, action] = data.split(":");
await ctx.editMessageText(action === "yes" ? `Deleted ${id}` : "Cancelled");
}
await ctx.answerCallbackQuery();
});
paginate(items, options)
項目をページに分割し、前へ/次へコントロールを付けます。
const result = paginate(allItems, {
page: 0, // 0-based, auto-clamped
perPage: 5,
callbackPrefix: "pg", // default: "page"
prevLabel: "« Prev",
nextLabel: "Next »",
});
// result.pageItems — items for current page
// result.totalPages — how many pages
// result.page — actual page number (may be clamped)
// result.controls — InlineKeyboardMarkup with prev/next buttons
1 ページの場合 → コントロールなし。最初のページの場合 → 「次へ」のみ。最後のページの場合 → 「前へ」のみ。中間のページの場合 → 両方。
ページネーションの完全なハンドラー:
async function showPage(ctx: BotContext, page: number) {
const items = await loadItems();
const { pageItems, controls } = paginate(items, { page, perPage: 5 });
const rows = pageItems.map(item => [
inlineButton(item.name, `select:${item.id}`),
]);
const keyboard = inlineKeyboard([...rows, ...controls.inline_keyboard]);
await ctx.editMessageText("Choose an item:", { reply_markup: keyboard });
}
bot.on("callback_query:data", async (ctx) => {
const data = ctx.callbackQuery.data;
if (data.startsWith("pg:next:")) await showPage(ctx, parseInt(data.split(":")[2]));
if (data.startsWith("pg:prev:")) await showPage(ctx, parseInt(data.split(":")[2]));
await ctx.answerCallbackQuery();
});
コールバック形式: <prefix>:prev:<n> / <prefix>:next:<n>。n は遷移先のページインデックスです。
4. Toolkit と Pure grammY の比較
| タスク | Pure grammY | Toolkit |
|---|---|---|
| インラインボタン | { text: "Hi", callback_data: "x" } | inlineButton("Hi", "x") |
| URL ボタン | { text: "Link", url: "..." } | urlButton("Link", "...") |
| テキストコピーボタン | { text: "Copy", copy_text: { text: "..." } } | copyTextButton("Copy", "...") |
| Web App ボタン | { text: "Open", web_app: { url: "..." } } | webAppButton("Open", "...") |
| グリッドメニュー | 手動で行に分割 | menuKeyboard(items, cols) |
| 確認用の行 | 2 ボタンの行を手動で作成 | confirmKeyboard("prefix") |
| ページネーション | 手動でのスライスと前後の処理 | paginate(items, opts) |
Toolkit のビルダーは、grammY が期待するものと同じ JSON の形を生成します。あくまで利便性のためのもので、依存を強制するものではありません。定型コードを減らしたいときに使ってください。完全な制御が必要な場合は使わなくてもかまいません。
よくある間違い
keyboardフィールドにinline_keyboardを指定する — これらは別のオブジェクトです。インラインキーボードはreply_markup.inline_keyboardの下に、返信キーボードはreply_markup.keyboardの下に指定します。- ハンドラーなしで
paginate()を使う — ボタンはpg:prev:X/pg:next:Xのデータを生成します。コールバックハンドラーでこれらを振り分ける必要があります。 - 未知のコールバックを処理しない — 想定外のコールバックデータに備えて、常にフォールバックの
answerCallbackQueryを用意してください。 - 新しいメッセージに
editMessageTextを使う — 以前の返信からmessage_idを取得する必要があります。最初のメッセージにはctx.reply()を、更新にはctx.editMessageText()を使います。 - 非 ASCII 文字を含む 64 バイトの
callback_data— Telegram はBUTTON_DATA_INVALIDで拒否します。上限は文字数ではなくバイト数です。telegram-bot-api-fundamentals§4 を参照してください。 - ステップフィルターなしで ForceReply を使う — チャット内のすべてのテキストメッセージが「X を待機中」のハンドラーをトリガーします。
ctx.session.stepで条件を指定してください。telegram-bot-ux§6 を参照してください。 - 返信キーボードで
resize_keyboard: trueを指定し、デスクトップでも有効だと想定する — Telegram Desktop 5.3.2+ ではresize_keyboardが無視されます。最大 4 列を前提に設計してください。 input_field_placeholderの指定漏れ — ユーザーには入力欄が空に見え、何を入力すればよいかわかりません。必ず設定してください。- 一度限りのプロンプトで
one_time_keyboard: false(デフォルト)を使う — ユーザーがタップした後もキーボードが表示されたままになります。「一度タップすれば完了」というフローではone_time_keyboard: trueを設定してください。 - ユーザーがすでに共有した後も
RequestContact用のカスタムキーボードを表示する — 古いキーボードが表示されたままになります。remove_keyboard: trueを指定した新しいメッセージを送信するか、キーボードを更新してください。