Adding Tap-to-Copy Approve Commands to Telegram Exec Approval Messages
How to implement ready-to-copy /approve command buttons in Telegram exec approval notifications to eliminate manual UUID copying on mobile.
π Symptoms
Current Telegram Exec Approval Message
When an exec approval is triggered, the Telegram bot sends:
π 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|denyUser Experience Problems
- Manual UUID copying β Users must long-press to select the UUID, which on mobile devices often results in incorrect selection due to UUID length and hyphen placement
- Command construction β After copying, users must manually type the command structure:
/approve+ space + paste UUID + space + action - High error rate β One mistyped character causes command rejection, requiring the entire process restart
- Mobile friction β The workflow requires 8-12 manual interactions versus a single tap
Observed Error Response
When a user sends an incorrectly formatted approve command:
Invalid approval ID format. Expected full UUID.
Use: /approve <uuid> allow-once|allow-always|denyPlatform Comparison
| Platform | Approval UX | Mechanism |
|---|---|---|
| Discord | One-click inline buttons | discord.js ButtonComponents |
| Slack | Interactive buttons | Block Kit interactive buttons |
| Telegram | Manual text entry | No native button support in this context |
π§ Root Cause
Technical Architecture Analysis
The Telegram exec approval workflow involves multiple components:
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β Exec Tool ββββββΆβ Approval Queue ββββββΆβ Telegram β
β (agent/core) β β (approval svc) β β Notifier β
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β
βΌ
ββββββββββββββββββββ
β approval ID β
β (full UUID) β
ββββββββββββββββββββRoot Cause Factors
1. Telegram Message Formatting Limitation
Telegram supports markdownv2 and HTML parse modes, but does not support inline keyboard buttons when the bot receives messages (only when the bot sends proactive messages). The ReplyKeyboardMarkup approach is not suitable for inline copy-paste workflows.
2. Code Message Template Deficiency
In packages/notifier-telegram/src/lib/format-message.ts (or equivalent), the approval message template constructs the human-readable text but omits the ready-to-use command blocks:
// 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');
};The template requires users to manually extract and reconstruct the command.
3. Short ID Non-Support
The /approve command handler only accepts full UUIDs, not short prefixes:
// 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.');
}This design decision prevents users from using the visible short prefix in the message.
4. Failed Alternative: WebSocket Event Subscription
An alternative approach was attempted using the gateway WebSocket connection:
// 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)The scoped event was not propagating to external subscribers, eliminating this approach.
π οΈ Step-by-Step Fix
Phase 1: Modify the Message Formatter
File: packages/notifier-telegram/src/lib/format-message.ts
Before:
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');
} After:
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');
}Note: In MarkdownV2, line breaks within code blocks prevent copy-paste. Each command must be on its own line. The backtick-wrapped commands ensure Telegram renders them as tappable code blocks.
Phase 2: Update Telegram Send Options
File: packages/notifier-telegram/src/lib/send-message.ts
Before:
const sendMessage = async (chatId: string, text: string) => {
await telegramBot.sendMessage(chatId, text, {
parse_mode: 'Markdown'
});
};After:
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
});
};Phase 3: Type Definition Update
File: 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
}Phase 4: Verify UUID Availability
Ensure the approval ID passed to the formatter is always the full UUID:
File: 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);
}π§ͺ Verification
Test Case 1: Message Renders with Copyable Commands
CLI Test Command:
# 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 messageExpected Telegram Output:
π *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`Verification Steps:
- Verify the message contains three distinct code blocks (backtick-wrapped)
- Tap each code block β Telegram should show "Copy" option
- Copy and send each command to the bot
- Confirm each command is accepted without "Invalid ID format" error
Test Case 2: Command Execution After Approval
CLI Test Command:
# 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 confirmationExpected Bot Response:
β
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.08Test Case 3: Verify Short ID Rejection
Test Command:
# Manually send a short ID to verify the bot rejects it
/s approve 25395703 allow-onceExpected Response:
β 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.Unit Test Verification
# 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β οΈ Common Pitfalls
Pitfall 1: MarkdownV2 Escaping Oversights
Telegram’s MarkdownV2 parser is strict. Unescaped special characters break the entire message.
Common Mistakes:
// β 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\``Escape Reference Table:
| Character | Escaped | Purpose |
|---|---|---|
_ | _ | Italic markers |
* | * | Bold markers |
` | ` | Code blocks |
( | ( | Link/format markers |
) | ) | Link/format markers |
- | - | List markers |
. | . | Can break parsing |
| | |
Pitfall 2: Command on Same Line as Text
β WRONG:
β
Tap to approve: `/approve ${id} allow-once`β CORRECT:
β
Tap to approve once:
`/approve ${id} allow-once`Telegram requires the code block to be on its own line to enable the tap-to-copy functionality.
Pitfall 3: Using Legacy Markdown Parse Mode
β WRONG:
parse_mode: 'Markdown' // Legacy, deprecatedβ CORRECT:
parse_mode: 'MarkdownV2' // Current, required for proper escapingPitfall 4: Command Injection in Approval ID
Never directly embed unsanitized approval IDs in messages:
// β DANGEROUS: Unsanitized ID could break parsing
`/approve ${approval.id} deny`
// β
SAFE: ID validated before message construction
const safeId = validateAndSanitizeUUID(approval.id);
`/approve ${safeId} deny`Pitfall 5: Copy-Paste on Desktop vs Mobile
Desktop Telegram clients handle code block tap-to-copy differently than mobile:
| Platform | Behavior | Recommendation |
|---|---|---|
| iOS | Long-press shows “Copy” | Works correctly |
| Android | Long-press shows “Copy” | Works correctly |
| Desktop macOS | Triple-click selects | Works correctly |
| Desktop Windows | Triple-click selects | Works correctly |
Always test on mobile devices as the primary use case.
Pitfall 6: Message Length Limits
Telegram messages are limited to 4096 characters. Long commands with full UUIDs may approach this limit:
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
});
}Pitfall 7: WebSocket Event Subscription (Historical)
When implementing alternative notification approaches:
// β 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 insteadThis was the failed alternative mentioned in the issue. The internal notifier pipeline remains the correct approach.
π Related Errors
Error Reference Table
| Error Code | Description | Cause | Resolution |
|---|---|---|---|
APPROVAL_001 | “Invalid approval ID format. Expected full UUID.” | Short ID or malformed UUID passed to /approve | Ensure message includes full UUID in copyable format |
APPROVAL_002 | “Approval request expired” | Approval TTL exceeded before decision | Implement shorter TTL or refresh notifications |
APPROVAL_003 | “Approval not found” | UUID not in approval store | UUID may have been cleared; request new approval |
APPROVAL_004 | “Unauthorized approver” | User not in approval whitelist | Add user to operator.approvals scope |
TG_001 | “Bot was blocked by the user” | Telegram bot cannot deliver message | User must unblock bot |
TG_002 | “Parse error: invalid JSON” | MarkdownV2 escaping error | Review character escaping |
TG_003 | “Message is too long” | Combined message exceeds 4096 chars | Truncate or split message |
WS_001 | “Event exec.approval.requested not received” | External WS clients don’t receive scoped events | Use internal notifier pipeline (see Pitfall 7) |
Related GitHub Issues
- #2147 β "Telegram inline keyboard support for approval actions" (closed, deferred β Telegram limitations)
- #1893 β "exec.approval.requested event not broadcast to external WebSocket clients" (open β possible bug)
- #1756 β "Short ID support for /approve command" (closed, won't fix β security concern)
- #1522 β "Discord approval buttons UX inconsistency with other channels" (resolved)
Security Considerations
The /approve command requires full UUID to prevent:
- Enumeration attacks on approval IDs
- Brute-force guessing of approval IDs
- Authorization bypass through short ID collision
The full UUID approach ensures that approval links cannot be predicted or harvested.