将 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 支持 markdownv2 和 HTML 解析模式,但当机器人接收消息时不支持内联键盘按钮(仅在机器人发送主动消息时才支持)。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`验证步骤:
- 验证消息包含三个独立的代码块(反引号包裹)
- 点击每个代码块 — 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 解析器非常严格。未转义的特殊字符会破坏整个消息。
常见错误:
// ❌ 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 方法确保审批链接无法被预测或收集。