April 19, 2026 • バージョン: latest

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 は markdownv2HTML 解析モードをサポートしていますが、ボットがメッセージを受信した場合はインラインキーボタンがサポートされていません(ボットがプロアクティブなメッセージを送信する場合のみ)。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`

検証手順:

  1. メッセージに3つの異なるコードブロック(バックティックでラップ)が含まれていることを確認
  2. 各コードブロックをタップ — Telegram に「コピー」オプションが表示されることを確認
  3. 各コマンドをコピーしてボットに送信
  4. 各コマンドが「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 アプローチにより、承認リンクが予測またはハーベストされることを防ぎます。

エビデンスとソース

このトラブルシューティングガイドは、FixClaw Intelligence パイプラインによってコミュニティの議論から自動的に合成されました。