April 19, 2026 • 版本: latest

将 Tap-to-Copy 审批命令添加到 Telegram 执行审批消息中

了解如何在 Telegram 执行审批通知中实现可直接复制的 /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 + 空格 + 操作
  • 错误率高 — 输入一个错误的字符就会导致命令被拒绝,需要重新开始整个流程
  • 移动端体验差 — 工作流程需要 8-12 次手动操作,而非一次点击即可完成

观察到的错误响应

当用户发送格式不正确的审批命令时:

Invalid approval ID format. Expected full UUID.
Use: /approve <uuid> allow-once|allow-always|deny

平台对比

平台审批用户体验机制
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(或等效文件)中,审批消息模板构造了人类可读的文本,但遗漏了可直接使用的命令块:

// Current implementation (simplified)
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,不支持短前缀:

// Command handler validates full 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 连接作为替代方案:

// Attempted external notifier approach
gateway.ws.on('exec.approval.requested', (event) => {
  // Send Telegram message with copyable commands
  await telegram.send({
    text: buildApprovalMessage(event),
    parse_mode: 'MarkdownV2'
  });
});

// Result: Event never received by external clients
// Root cause: exec\.approval\.requested events not broadcast to
// clients with operator.approvals scope (possible bug in event routing)

作用域事件没有传播到外部订阅者,因此该方案被排除。

🛠️ 逐步修复

第一阶段:修改消息格式化器

文件: 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 {
  // Escape special characters for 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 将其渲染为可点击的代码块。

第二阶段:更新 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) => {
  // Replace placeholder with actual line break (MarkdownV2 compatible)
  const processedText = text.replace(
    /\{ESCAPED_BREAK_POINT\}/g,
    '\\- \\- \\-'
  );
  
  await telegramBot.sendMessage(chatId, processedText, {
    parse_mode: 'MarkdownV2',
    disable_web_page_preview: true
  });
};

第三阶段:类型定义更新

文件: packages/notifier-telegram/src/types/approval.ts

export interface ExecApproval {
  id: string;              // Full UUID (required for /approve command)
  command: string;         // The exec command being approved
  triggeredBy: string;     // Operator/username who triggered
  timestamp: string;       // ISO 8601 timestamp
  status?: 'pending' | 'approved' | 'denied';
  expiresAt?: string;      // Optional expiration
}

第四阶段:验证 UUID 可用性

确保传递给格式化器的审批 ID 始终是完整 UUID:

文件: packages/notifier-telegram/src/lib/handle-approval.ts

import { validateUUID } from '@openclaw/shared-utils';

// Ensure we're always working with full UUIDs
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. 验证消息包含三个独立的代码块(反引号包裹)
  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 解析器非常严格。未转义的特殊字符会破坏整个消息。

常见错误:

// ❌ WRONG: Unescaped parentheses and hyphens
`/approve ${id} allow-once`

// ✅ CORRECT: Escaped special characters
`/approve ${escapedId} allow\\-once`

// ❌ WRONG: Unescaped dots in UUID context
`command: uptime`

// ✅ CORRECT: Escaped if using MarkdownV2 bold
`\\*Command\\:* \`uptime\``

转义参考表:

字符转义后用途
__斜体标记
**粗体标记
``代码块
((链接/格式标记
))链接/格式标记
--列表标记
..可能破坏解析
|

陷阱 2:命令与文本在同一行

❌ 错误:

✅ Tap to approve: `/approve ${id} allow-once`

✅ 正确:

✅ Tap to approve once:
`/approve ${id} allow-once`

Telegram 要求代码块单独一行才能启用点击复制功能。

陷阱 3:使用过时的 Markdown 解析模式

❌ 错误:

parse_mode: 'Markdown'  // Legacy, deprecated

✅ 正确:

parse_mode: 'MarkdownV2'  // Current, required for proper escaping

陷阱 4:审批 ID 中的命令注入

永远不要将未清理的审批 ID 直接嵌入消息中:

// ❌ DANGEROUS: Unsanitized ID could break parsing
`/approve ${approval.id} deny`

// ✅ SAFE: ID validated before message construction
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) {
  // Truncate command display or use abbreviated format
  logger.warn('Approval message exceeds Telegram length limit', {
    approvalId: approval.id,
    messageLength: formattedMessage.length
  });
}

陷阱 7:WebSocket 事件订阅(历史记录)

在实现替代通知方案时:

// ❌ Attempting to subscribe to approval events via gateway WS
gateway.subscribe('exec.approval.requested', handler);

// Result: Event not received
// Reason: exec.* events are not broadcast to external scope subscribers
// Workaround: Use internal notifier pipeline instead

这是问题中提到的失败替代方案。内部通知器管道仍然是正确的方法。

🔗 相关错误

错误参考表

错误代码描述原因解决方案
APPROVAL_001“Invalid approval ID format. Expected full UUID.”传递给 /approve 的是短 ID 或格式错误的 UUID确保消息中包含可复制格式的完整 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”组合消息超过 4096 字符截断或拆分消息
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"(开放中 — 可能存在的 bug)
  • #1756 — "Short ID support for /approve command"(已关闭,不会修复 — 安全考虑)
  • #1522 — "Discord approval buttons UX inconsistency with other channels"(已解决)

安全考虑

/approve 命令需要完整 UUID 以防止:

  • 对审批 ID 的枚举攻击
  • 对审批 ID 的暴力猜测
  • 通过短 ID 碰撞绕过授权

完整 UUID 方法确保审批链接无法被预测或收集。

依据与来源

本故障排除指南由 FixClaw 智能管线从社区讨论中自动合成。