Agregar comandos de aprobación Tap-to-Copy a los mensajes de aprobación exec de Telegram
Cómo implementar botones de comando /approve listos para copiar en las notificaciones de aprobación exec de Telegram para eliminar copiar UUIDs manualmente en dispositivos móviles.
🔍 Síntomas
Mensaje actual de aprobación exec en Telegram
Cuando se activa una aprobación exec, el bot de Telegram envía:
🔒 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|denyProblemas de experiencia de usuario
- Copiado manual del UUID — Los usuarios deben mantener presionado para seleccionar el UUID, lo que en dispositivos móviles a menudo resulta en una selección incorrecta debido a la longitud del UUID y la ubicación de los guiones
- Construcción del comando — Después de copiar, los usuarios deben escribir manualmente la estructura del comando:
/approve+ espacio + pegar UUID + espacio + acción - Alta tasa de errores — Un solo carácter mal escrito provoca el rechazo del comando, requiriendo reiniciar todo el proceso
- Fricción en móvil — El flujo de trabajo requiere 8-12 interacciones manuales en lugar de un solo toque
Respuesta de error observada
Cuando un usuario envía un comando de aprobación con formato incorrecto:
Invalid approval ID format. Expected full UUID.
Use: /approve <uuid> allow-once|allow-always|denyComparación de plataformas
| Plataforma | UX de aprobación | Mecanismo |
|---|---|---|
| Discord | Botones en línea con un clic | discord.js ButtonComponents |
| Slack | Botones interactivos | Block Kit interactive buttons |
| Telegram | Entrada de texto manual | Sin soporte nativo de botones en este contexto |
🧠 Causa raíz
Análisis de arquitectura técnica
El flujo de trabajo de aprobación exec de Telegram involucra múltiples componentes:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Exec Tool │────▶│ Approval Queue │────▶│ Telegram │
│ (agent/core) │ │ (approval svc) │ │ Notifier │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
▼
┌──────────────────┐
│ approval ID │
│ (full UUID) │
└──────────────────┘Factores de causa raíz
1. Limitación de formato de mensaje de Telegram
Telegram soporta modos de análisis markdownv2 y HTML, pero no soporta botones de teclado en línea cuando el bot recibe mensajes (solo cuando el bot envía mensajes proactivos). El enfoque ReplyKeyboardMarkup no es adecuado para flujos de trabajo de copiar y pegar.
2. Deficiencia en la plantilla de mensaje de código
En packages/notifier-telegram/src/lib/format-message.ts (o equivalente), la plantilla del mensaje de aprobación construye el texto legible pero omite los bloques de comando listos para usar:
// 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');
};La plantilla requiere que los usuarios extraigan y reconstruyan manualmente el comando.
3. No soporte de ID corto
El manejador del comando /approve solo acepta UUIDs completos, no prefijos cortos:
// 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.');
}Esta decisión de diseño impide que los usuarios usen el prefijo corto visible en el mensaje.
4. Alternativa fallida: Suscripción a eventos WebSocket
Se intentó un enfoque alternativo usando la conexión WebSocket de la puerta de enlace:
// 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)El evento con ámbito no se propagaba a suscriptores externos, eliminando este enfoque.
🛠️ Solución paso a paso
Fase 1: Modificar el formateador de mensajes
Archivo: packages/notifier-telegram/src/lib/format-message.ts
Antes:
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');
} Después:
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');
}Nota: En MarkdownV2, los saltos de línea dentro de los bloques de código impiden el copiar y pegar. Cada comando debe estar en su propia línea. Los comandos envueltos en acentos graves aseguran que Telegram los renderice como bloques de código tocables.
Fase 2: Actualizar opciones de envío de Telegram
Archivo: packages/notifier-telegram/src/lib/send-message.ts
Antes:
const sendMessage = async (chatId: string, text: string) => {
await telegramBot.sendMessage(chatId, text, {
parse_mode: 'Markdown'
});
};Después:
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
});
};Fase 3: Actualización de definición de tipo
Archivo: 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
}Fase 4: Verificar disponibilidad del UUID
Asegurar que el ID de aprobación pasado al formateador siempre sea el UUID completo:
Archivo: 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);
}🧪 Verificación
Caso de prueba 1: El mensaje se renderiza con comandos copiables
Comando de prueba 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 messageSalida esperada en 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`Pasos de verificación:
- Verificar que el mensaje contiene tres bloques de código distintos (envueltos en acentos graves)
- Tocar cada bloque de código — Telegram debería mostrar la opción "Copiar"
- Copiar y enviar cada comando al bot
- Confirmar que cada comando es aceptado sin error de "Invalid ID format"
Caso de prueba 2: Ejecución de comando después de la aprobación
Comando de prueba 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 confirmationRespuesta esperada del bot:
✅ 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.08Caso de prueba 3: Verificar rechazo de ID corto
Comando de prueba:
# Manually send a short ID to verify the bot rejects it
/s approve 25395703 allow-onceRespuesta esperada:
❌ 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.Verificación de pruebas unitarias
# 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⚠️ Errores comunes
Error común 1: Omisiones de escape en MarkdownV2
El analizador de MarkdownV2 de Telegram es estricto. Los caracteres especiales sin escapar rompen todo el mensaje.
Errores comunes:
// ❌ 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\``Tabla de referencia de escape:
| Carácter | Con escape | Propósito |
|---|---|---|
_ | _ | Marcadores de cursiva |
* | * | Marcadores de negrita |
` | ` | Bloques de código |
( | ( | Marcadores de enlace/formato |
) | ) | Marcadores de enlace/formato |
- | - | Marcadores de lista |
. | . | Puede romper el análisis |
| | |
Error común 2: Comando en la misma línea que el texto
❌ INCORRECTO:
✅ Tap to approve: `/approve ${id} allow-once`✅ CORRECTO:
✅ Tap to approve once:
`/approve ${id} allow-once`Telegram requiere que el bloque de código esté en su propia línea para habilitar la funcionalidad de tocar para copiar.
Error común 3: Usar modo de análisis de Markdown heredado
❌ INCORRECTO:
parse_mode: 'Markdown' // Legacy, deprecated✅ CORRECTO:
parse_mode: 'MarkdownV2' // Current, required for proper escapingError común 4: Inyección de comandos en el ID de aprobación
Nunca insertar IDs de aprobación sin sanitizar directamente en los mensajes:
// ❌ DANGEROUS: Unsanitized ID could break parsing
`/approve ${approval.id} deny`
// ✅ SAFE: ID validated before message construction
const safeId = validateAndSanitizeUUID(approval.id);
`/approve ${safeId} deny`Error común 5: Copiar y pegar en escritorio vs móvil
Los clientes de Telegram de escritorio manejan el tocar para copiar de bloques de código de manera diferente a los móviles:
| Plataforma | Comportamiento | Recomendación |
|---|---|---|
| iOS | Mantener presionado muestra “Copiar” | Funciona correctamente |
| Android | Mantener presionado muestra “Copiar” | Funciona correctamente |
| Desktop macOS | Triple clic selecciona | Funciona correctamente |
| Desktop Windows | Triple clic selecciona | Funciona correctamente |
Siempre probar en dispositivos móviles como caso de uso principal.
Error común 6: Límites de longitud del mensaje
Los mensajes de Telegram están limitados a 4096 caracteres. Los comandos largos con UUIDs completos pueden acercarse a este límite:
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
});
}Error común 7: Suscripción a eventos WebSocket (Histórico)
Al implementar enfoques alternativos de notificación:
// ❌ 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 insteadEsta fue la alternativa fallida mencionada en el issue. La tubería de notificación interna sigue siendo el enfoque correcto.
🔗 Errores relacionados
Tabla de referencia de errores
| Código de error | Descripción | Causa | Resolución |
|---|---|---|---|
APPROVAL_001 | “Invalid approval ID format. Expected full UUID.” | ID corto o UUID mal formado pasado a /approve | Asegurar que el mensaje incluya el UUID completo en formato copiable |
APPROVAL_002 | “Approval request expired” | TTL de aprobación excedido antes de la decisión | Implementar TTL más corto o notificaciones de renovación |
APPROVAL_003 | “Approval not found” | UUID no está en el almacén de aprobaciones | El UUID puede haber sido limpiado; solicitar nueva aprobación |
APPROVAL_004 | “Unauthorized approver” | Usuario no está en la lista blanca de aprobación | Agregar usuario al ámbito operator.approvals |
TG_001 | “Bot was blocked by the user” | El bot de Telegram no puede entregar el mensaje | El usuario debe desbloquear el bot |
TG_002 | “Parse error: invalid JSON” | Error de escape de MarkdownV2 | Revisar el escape de caracteres |
TG_003 | “Message is too long” | Mensaje combinado excede 4096 caracteres | Truncar o dividir el mensaje |
WS_001 | “Event exec.approval.requested not received” | Los clientes WS externos no reciben eventos con ámbito | Usar tubería de notificación interna (ver Error común 7) |
Issues de GitHub relacionados
- #2147 — "Telegram inline keyboard support for approval actions" (cerrado, diferido — limitaciones de Telegram)
- #1893 — "exec.approval.requested event not broadcast to external WebSocket clients" (abierto — posible bug)
- #1756 — "Short ID support for /approve command" (cerrado, no se implementará — preocupación de seguridad)
- #1522 — "Discord approval buttons UX inconsistency with other channels" (resuelto)
Consideraciones de seguridad
El comando /approve requiere UUID completo para prevenir:
- Ataques de enumeración en IDs de aprobación
- Adivinanza por fuerza bruta de IDs de aprobación
- Elusión de autorización a través de colisión de ID corto
El enfoque de UUID completo asegura que los enlaces de aprobación no puedan ser predichos o cosechados.