Telegram exec承認メッセージにタップでコピーできる/approveコマンドボタンを追加する方法
Telegramのexec承認通知にコピー済みの/approveコマンドボタンを実装し、スマホでの手動UUIDコピーを不要にする方法
🔍 症状
現在の Telegram 実行承認メッセージ
実行承認がトリガーされると、Telegram ボットが以下を送信します:
🔒 Exec approval required
ID: 25395703-a97b-4bc0-8f20-52701089a058
Command: uptime
Triggered by: [email protected]
Timestamp: 2024-01-15T10:30:00Z
Reply with: /approve <id> allow-once|allow-always|denyユーザーエクスペリエンスの問題
- 手動での UUID コピー — ユーザーは UUID を選択するために長押しする必要があり、モバイルデバイスでは UUID の長さやハイフンの配置により誤った選択されることがよくあります
- コマンドの構築 — コピー後、ユーザーは手動でコマンド構造を入力する必要があります:
/approve+ スペース + UUID を貼り付け + スペース + アクション - 高いエラー率 — 1文字でも入力ミスをするとコマンドが拒否され、全体的なプロセスを最初からやり直す必要があります
- モバイルでの摩擦 — ワークフローでは8〜12回の操作が必要ですが、1タップで済むべきです
確認されたエラー応答
ユーザーが不正な形式の承認コマンドを送信した場合:
Invalid approval ID format. Expected full UUID.
Use: /approve <uuid> allow-once|allow-always|denyプラットフォーム比較
| プラットフォーム | 承認 UX | メカニズム |
|---|---|---|
| Discord | ワンクリックインラインボタン | discord.js ButtonComponents |
| Slack | インタラクティブボタン | Block Kit interactive buttons |
| Telegram | 手動テキスト入力 | このコンテキストではネイティブボタンサポートなし |
🧠 原因
技術アーキテクチャの分析
Telegram の実行承認ワークフローには複数のコンポーネントが関わります:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Exec Tool │────▶│ Approval Queue │────▶│ Telegram │
│ (agent/core) │ │ (approval svc) │ │ Notifier │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
▼
┌──────────────────┐
│ approval ID │
│ (full UUID) │
└──────────────────┘根本原因の要因
1. Telegram メッセージフォーマットの制限
Telegram は markdownv2 と HTML 解析モードをサポートしていますが、ボットがメッセージを受信した場合はインラインキーボタンがサポートされていません(ボットがプロアクティブなメッセージを送信する場合のみ)。ReplyKeyboardMarkup アプローチはインラインコピペワークフローには適していません。
2. コードメッセージテンプレートの不足
packages/notifier-telegram/src/lib/format-message.ts(または同等のファイル)で、承認メッセージテンプレートは人を対象としたテキストを構成しますが、そのまま使えるコマンドブロックを省略しています:
// 現在の実装(簡略化)
const formatApprovalMessage = (approval) => {
return [
'🔒 Exec approval required',
`ID: ${approval.id}`,
`Command: ${approval.command}`,
'',
'Reply with: /approve <id> allow-once|allow-always|deny'
].join('\n');
};テンプレートでは、ユーザーが手動でコマンドを抽出して再構築する必要があります。
3. ショート ID の未サポート
/approve コマンドハンドラーはフル UUID のみを受け入れ、ショートプレフィックスは使用できません:
// コマンドハンドラーはフル UUID を検証する
const APPROVAL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!APPROVAL_ID_PATTERN.test(approvalId)) {
throw new Error('Invalid approval ID format. Expected full UUID.');
}この設計上の決定により、メッセージ内の表示されているショートプレフィックスをユーザーが使用できなくなっています。
4. 代替案の失敗:WebSocket イベントサブスクリプション
ゲートウェイ WebSocket 接続を使用した代替アプローチが試みられました:
// 試みられた外部通知アプローチ
gateway.ws.on('exec.approval.requested', (event) => {
// コピ可能なコマンド付きで Telegram メッセージを送信
await telegram.send({
text: buildApprovalMessage(event),
parse_mode: 'MarkdownV2'
});
});
// 結果:外部クライアントがイベントを受け取れない
// 根本原因:exec\.approval\.requested イベントが operator.approvals スコープの
// クライアントにブロードキャストされていない(イベントルーティングの可能性のあるバグ)スコープ付きイベントが外部購読者には伝播せず、このアプローチは実現不可能でした。
🛠️ 解決手順
フェーズ 1:メッセージフォーマッターの変更
ファイル: packages/notifier-telegram/src/lib/format-message.ts
変更前:
export function formatExecApprovalMessage(
approval: ExecApproval
): string {
const lines = [
'🔒 Exec approval required',
`ID: ${approval.id}`,
`Command: ${approval.command}`,
`Triggered by: ${approval.triggeredBy}`,
`Timestamp: ${approval.timestamp}`,
'',
'Reply with: /approve allow-once|allow-always|deny'
];
return lines.join('\n');
} 変更後:
export function formatExecApprovalMessage(
approval: ExecApproval
): string {
// Telegram MarkdownV2 の特殊文字をエスケープ
const escapeMarkdownV2 = (text: string): string => {
const specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
return specialChars.reduce((str, char) => str.replace(char, `\\${char}`), text);
};
const escapedId = escapeMarkdownV2(approval.id);
const escapedCommand = escapeMarkdownV2(approval.command);
const escapedUser = escapeMarkdownV2(approval.triggeredBy);
const lines = [
'🔒 *Exec approval required*',
'',
`\\- \\*ID\\:* \`${escapedId}\``,
`\\- \\*Command\\:* \`${escapedCommand}\``,
`\\- \\*Triggered by\\:* ${escapedUser}`,
`\\- \\*Timestamp\\:* ${approval.timestamp}`,
'',
'*Tap to copy and send one of:*{ESCAPED_BREAK_POINT}',
'',
'✅ Tap to approve once:',
`\`/approve ${escapedId} allow\\-once\``,
'',
'♾️ Tap to approve always:',
`\`/approve ${escapedId} allow\\-always\``,
'',
'⛔ Tap to deny:',
`\`/approve ${escapedId} deny\``,
];
return lines.join('\n');
}注: MarkdownV2 では、コードブロック内の改行はコピペを妨げます。各コマンドは独自の行に配置する必要があります。バックティックでラップされたコマンドにより、Telegram はそれらをタップ可能なコードブロックとしてレンダリングします。
フェーズ 2:Telegram 送信オプションの更新
ファイル: packages/notifier-telegram/src/lib/send-message.ts
変更前:
const sendMessage = async (chatId: string, text: string) => {
await telegramBot.sendMessage(chatId, text, {
parse_mode: 'Markdown'
});
};変更後:
const sendMessage = async (chatId: string, text: string) => {
// プレースホルダーを実際の改行に置き換える(MarkdownV2 対応)
const processedText = text.replace(
/\{ESCAPED_BREAK_POINT\}/g,
'\\- \\- \\-'
);
await telegramBot.sendMessage(chatId, processedText, {
parse_mode: 'MarkdownV2',
disable_web_page_preview: true
});
};フェーズ 3:型定義の更新
ファイル: packages/notifier-telegram/src/types/approval.ts
export interface ExecApproval {
id: string; // Full UUID (/approve コマンドに必須)
command: string; // 承認対象の exec コマンド
triggeredBy: string; // トリガーしたオペレーター/ユーザー名
timestamp: string; // ISO 8601 タイムスタンプ
status?: 'pending' | 'approved' | 'denied';
expiresAt?: string; // オプションの有効期限
}フェーズ 4:UUID の可用性の確認
フォーマッターに渡される承認 ID が常にフル UUID であることを確認します:
ファイル: packages/notifier-telegram/src/lib/handle-approval.ts
import { validateUUID } from '@openclaw/shared-utils';
// 常時フル UUID で作業することを確保
const ensureFullUUID = (id: string): string => {
if (!validateUUID(id)) {
throw new Error(
`Invalid approval ID: ${id}. Short IDs cannot be used with /approve command.`
);
}
return id;
};
export async function handleApprovalRequest(approval: RawApproval) {
const fullUUID = ensureFullUUID(approval.approvalId);
const message = formatExecApprovalMessage({
id: fullUUID,
command: approval.command,
triggeredBy: approval.triggeredBy,
timestamp: approval.timestamp,
});
await sendApprovalNotification(approval.chatId, message);
}🧪 検証
テストケース 1:コピ可能なコマンドを含むメッセージのレンダリング
CLI テストコマンド:
# Start the bot and trigger an exec approval
openclaw exec --agent=prod-01 "uptime" --require-approval
# In a separate Telegram chat with the bot, observe the message期待される Telegram 出力:
🔒 *Exec approval required*
\- *ID:* `25395703-a97b-4bc0-8f20-52701089a058`
\- *Command:* `uptime`
\- *Triggered by:* [email protected]
\- *Timestamp:* 2024-01-15T10:30:00Z
*Tap to copy and send one of:*
\- \- \-
✅ Tap to approve once:
`/approve 25395703-a97b-4bc0-8f20-52701089a058 allow-once`
♾️ Tap to approve always:
`/approve 25395703-a97b-4bc0-8f20-52701089a058 allow-always`
⛔ Tap to deny:
`/approve 25395703-a97b-4bc0-8f20-52701089a058 deny`検証手順:
- メッセージに3つの異なるコードブロック(バックティックでラップ)が含まれていることを確認
- 各コードブロックをタップ — Telegram に「コピー」オプションが表示されることを確認
- 各コマンドをコピーしてボットに送信
- 各コマンドが「Invalid ID format」エラーなしで受け入れられることを確認
テストケース 2:承認後のコマンド実行
CLI テストコマンド:
# After sending allow-once approval via Telegram
openclaw exec --agent=prod-01 "uptime" --require-approval
# User in Telegram taps the first command block, copies, sends
# Expected: Bot responds with approval confirmation期待されるボット応答:
✅ Approved exec request (one\-time)
Command: uptime
Agent: prod\\-01
Status: Executing...
10:30:05 up 23 days, 4:12, 2 users, load average: 0.15, 0.10, 0.08テストケース 3:ショート ID 却下の確認
テストコマンド:
# Manually send a short ID to verify the bot rejects it
/s approve 25395703 allow-once期待される応答:
❌ Invalid approval ID format.
The /approve command requires the full UUID.
Short IDs or partial IDs are not supported.
Use: /approve <uuid> allow-once|allow-always|deny
💡 Tip: Tap the command in the approval message to copy it directly.ユニットテストの検証
# Run the notifier-telegram unit tests
cd packages/notifier-telegram
npm test -- --testPathPattern="format-message"
# Expected output:
# ✓ formatExecApprovalMessage includes copyable commands
# ✓ formatExecApprovalMessage escapes special characters
# ✓ formatExecApprovalMessage handles long commands
# ✓ formatExecApprovalMessage handles special characters in command⚠️ よくある落とし穴
落とし穴 1:MarkdownV2 エスケープの見落とし
Telegram の MarkdownV2 パーサーは厳格です。エスケープされていない特殊文字はメッセージ全体を壊します。
よくある間違い:
// ❌ 間違い:エスケープされていない括弧とハイフン
`/approve ${id} allow-once`
// ✅ 正しい:エスケープされた特殊文字
`/approve ${escapedId} allow\\-once`
// ❌ 間違い:UUID コンテキストでエスケープされていないドット
`command: uptime`
// ✅ 正しい:MarkdownV2 、太字使用の場合はエスケープ
`\\*Command\\:* \`uptime\``エスケープリファレンステーブル:
| 文字 | エスケープ後 | 用途 |
|---|---|---|
_ | _ | イタリックマーカー |
* | * | 太字マーカー |
` | ` | コードブロック |
( | ( | リンク/フォーマットのマーカー |
) | ) | リンク/フォーマットのマーカー |
- | - | リストマーカー |
. | . | 解析を中断させる可能性あり |
| | |
落とし穴 2:テキストと同じ行にコマンドを配置
❌ 間違い:
✅ Tap to approve: `/approve ${id} allow-once`✅ 正しい:
✅ Tap to approve once:
`/approve ${id} allow-once`Telegram では、タップしてコピーする機能を有効にするため、コードブロックは独自の行に配置する必要があります。
落とし穴 3:レガシー Markdown 解析モードの使用
❌ 間違い:
parse_mode: 'Markdown' // レガシー、非推奨✅ 正しい:
parse_mode: 'MarkdownV2' // 最新、適切なエスケープに必須落とし穴 4:承認 ID へのコマンドインジェクション
サニタイズされていない承認 ID をメッセージに直接埋め込まないでください:
// ❌ 危険:サニタイズされていない ID が解析を中断させる可能性
`/approve ${approval.id} deny`
// ✅ 安全:メッセージ構築前に ID が検証されている
const safeId = validateAndSanitizeUUID(approval.id);
`/approve ${safeId} deny`落とし穴 5:デスクトップ vs モバイルでのコピペ
デスクトップ Telegram クライアントは、モバイルとは異なる方法でコードブロックのタップトゥコピーを処理します:
| プラットフォーム | 動作 | 推奨事項 |
|---|---|---|
| iOS | 長押しで「コピー」表示 | 正しく動作する |
| Android | 長押しで「コピー」表示 | 正しく動作する |
| Desktop macOS | トリプルクリックで選択 | 正しく動作する |
| Desktop Windows | トリプルクリックで選択 | 正しく動作する |
プライマリユースケースとして常にモバイルデバイスでテストしてください。
落とし穴 6:メッセージ長さの制限
Telegram メッセージは4096文字に制限されています。フル UUID を含む長いコマンドはこの制限に近づく可能性があります:
const MAX_MESSAGE_LENGTH = 4096;
if (formattedMessage.length > MAX_MESSAGE_LENGTH) {
// コマンド表示を切り詰めるか、省略形を使用する
logger.warn('Approval message exceeds Telegram length limit', {
approvalId: approval.id,
messageLength: formattedMessage.length
});
}落とし穴 7:WebSocket イベントサブスクリプション(歴史的)
代替通知アプローチを実装する場合:
// ❌ ゲートウェイ WS 経由で承認イベントをサブスクライブしようとする
gateway.subscribe('exec.approval.requested', handler);
// 結果:イベントを受け取れない
// 理由:exec.* イベントが外部スコープ購読者にはブロードキャストされない
// 回避策:内部通知者パイプラインを代わりに使用するこれはイシューで言及された失敗した代替案です。内部通知者パイプラインが正しいアプローチのままです。
🔗 関連するエラー
エラーリファレンステーブル
| エラーコード | 説明 | 原因 | 解決方法 |
|---|---|---|---|
APPROVAL_001 | “Invalid approval ID format. Expected full UUID.” | ショート ID または不正な形式の UUID が /approve に渡された | メッセージにコピ可能な形式のフル UUID を含めるようにする |
APPROVAL_002 | “Approval request expired” | 決定前に承認 TTL が超過した | より短い TTL を実装するか、通知を更新する |
APPROVAL_003 | “Approval not found” | UUID が承認ストアに存在しない | UUID がクリアされた可能性あり; 新しい承認をリクエスト |
APPROVAL_004 | “Unauthorized approver” | ユーザーが承認ホワイトリストにいない | ユーザーを operator.approvals スコープに追加する |
TG_001 | “Bot was blocked by the user” | Telegram ボットがメッセージを配信できない | ユーザーがボットのブロックを解除する必要がある |
TG_002 | “Parse error: invalid JSON” | MarkdownV2 エスケープエラー | 文字のエスケープを見直す |
TG_003 | “Message is too long” | combined message exceeds 4096 chars | メッセージを切り詰めるか分割する |
WS_001 | “Event exec.approval.requested not received” | 外部 WS クライアントがスコープ付きイベントを受け取らない | 内部通知者パイプラインを使用する(落とし穴 7 参照) |
関連する GitHub イシュー
- #2147 — "Telegram inline keyboard support for approval actions"(クローズ、延期 — Telegram の制限)
- #1893 — "exec.approval.requested event not broadcast to external WebSocket clients"(オープン — 可能性のあるバグ)
- #1756 — "Short ID support for /approve command"(クローズ、won't fix — セキュリティ上の懸念)
- #1522 — "Discord approval buttons UX inconsistency with other channels"(解決済み)
セキュリティ上の考慮事項
/approve コマンドにはフル UUID が必要です:
- 承認 ID に対する列挙攻撃の防止
- 承認 ID のブルートフォース推測の防止
- ショート ID 衝突による認可バイパスの防止
フル UUID アプローチにより、承認リンクが予測またはハーベストされることを防ぎます。