From 7b0e4b9b5c761f0c8bc1ea48606c89a3870d6191 Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 3 Aug 2026 02:26:42 +0000 Subject: [PATCH] fix: convertir Markdown a HTML de Telegram antes de enviar mensajes del agente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El bot manda mensajes con parse_mode=HTML, pero la IA responde en Markdown (##, **negrita**, listas con "-", separadores ---), que Telegram no interpreta — salía todo literal con los símbolos. Ahora FormatearParaTelegram convierte el Markdown a las etiquetas que Telegram sí soporta (b, code, pre, a) justo antes de enviar, escapando cualquier < > & literal para que sendMessage tampoco falle por HTML inválido. Se ajustó el único lugar que ya armaba HTML a mano (/instancias) para que use el mismo formato Markdown y pase por el mismo conversor. Incluye tests con el caso real reportado. Co-Authored-By: Claude Sonnet 5 --- pkg/services/telegram_agent_service.go | 4 +- pkg/services/telegram_format_service.go | 82 +++++++++++++++++++ pkg/services/telegram_format_service_test.go | 70 ++++++++++++++++ rest/controllers/telegram_agent_controller.go | 2 +- 4 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 pkg/services/telegram_format_service.go create mode 100644 pkg/services/telegram_format_service_test.go diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index ddf1b36..5e38490 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -1396,9 +1396,9 @@ Comandos: /reset /instancias /ayuda`, nil if !c.Activo { estado = "✗ inactiva" } - lines[i] = fmt.Sprintf("• #%d %s — %s (%s)", c.ID, c.Nombre, c.BaseURL, estado) + lines[i] = fmt.Sprintf("• #%d **%s** — %s (%s)", c.ID, c.Nombre, c.BaseURL, estado) } - return "Instancias de Coolify:\n" + strings.Join(lines, "\n"), nil + return "**Instancias de Coolify:**\n" + strings.Join(lines, "\n"), nil } // Cargar historial (últimos 20 mensajes) diff --git a/pkg/services/telegram_format_service.go b/pkg/services/telegram_format_service.go new file mode 100644 index 0000000..21226b4 --- /dev/null +++ b/pkg/services/telegram_format_service.go @@ -0,0 +1,82 @@ +package services + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + reCodeBlock = regexp.MustCompile("(?s)```(?:[a-zA-Z0-9]*\n)?(.*?)```") + reInlineCode = regexp.MustCompile("`([^`\n]+)`") + reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`) + reHeader = regexp.MustCompile(`(?m)^#{1,6}[ \t]+(.+)$`) + reBullet = regexp.MustCompile(`(?m)^(\s*)[-*][ \t]+`) + reHRSimple = regexp.MustCompile(`(?m)^[ \t]*(-{3,}|_{3,}|\*{3,})[ \t]*\n?`) + reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`) +) + +// FormatearParaTelegram convierte el Markdown que suele escribir la IA (##, +// **negrita**, listas con "-", ``` código ```, separadores ---) a las pocas +// etiquetas HTML que Telegram sí entiende con parse_mode=HTML (b, i, code, pre, +// a). Escapa primero cualquier < > & literal del texto original para que +// sendMessage nunca falle por HTML inválido si la IA menciona código o símbolos. +func FormatearParaTelegram(text string) string { + // 1. Proteger bloques de código ANTES de escapar el resto, para no + // procesarles negrita/viñetas por accidente. + var blocks []string + protect := func(html string) string { + blocks = append(blocks, html) + return fmt.Sprintf("\x00%d\x00", len(blocks)-1) + } + text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { + sub := reCodeBlock.FindStringSubmatch(m) + return protect("
" + escapeTelegramHTML(strings.TrimSpace(sub[1])) + "
") + }) + text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { + sub := reInlineCode.FindStringSubmatch(m) + return protect("" + escapeTelegramHTML(sub[1]) + "") + }) + + // 2. Escapar el resto del texto como HTML. + text = escapeTelegramHTML(text) + + // 3. Separadores horizontales estilo markdown (---, ___, ***) fuera. + text = reHRSimple.ReplaceAllString(text, "") + + // 4. Negrita (antes que encabezados, para no anidar si un título + // también trae **negrita** adentro). + text = reBold.ReplaceAllStringFunc(text, func(m string) string { + sub := reBold.FindStringSubmatch(m) + inner := sub[1] + if inner == "" { + inner = sub[2] + } + return "" + inner + "" + }) + + // 5. Encabezados (#, ##, ###...) → negrita, sin duplicar si ya la trae. + text = reHeader.ReplaceAllStringFunc(text, func(m string) string { + sub := reHeader.FindStringSubmatch(m) + inner := strings.NewReplacer("", "", "", "").Replace(sub[1]) + return "" + inner + "" + }) + + // 6. Viñetas "- " o "* " al inicio de línea → "• ". + text = reBullet.ReplaceAllString(text, "$1• ") + + // 7. Enlaces [texto](url). + text = reLink.ReplaceAllString(text, `$1`) + + // 8. Restaurar bloques de código protegidos en el paso 1. + for i, b := range blocks { + text = strings.ReplaceAll(text, fmt.Sprintf("\x00%d\x00", i), b) + } + + // Máximo 3 saltos de línea seguidos (Markdown suele dejar párrafos sueltos). + for strings.Contains(text, "\n\n\n\n") { + text = strings.ReplaceAll(text, "\n\n\n\n", "\n\n\n") + } + + return strings.TrimSpace(text) +} diff --git a/pkg/services/telegram_format_service_test.go b/pkg/services/telegram_format_service_test.go new file mode 100644 index 0000000..a25da31 --- /dev/null +++ b/pkg/services/telegram_format_service_test.go @@ -0,0 +1,70 @@ +package services + +import ( + "strings" + "testing" +) + +func TestFormatearParaTelegram(t *testing.T) { + cases := []struct { + name string + in string + wantContains []string + wantNotContains []string + }{ + { + name: "encabezado y negrita", + in: "## ✅ **¡Factura registrada exitosamente!**", + wantContains: []string{"✅ ¡Factura registrada exitosamente!"}, + wantNotContains: []string{"##", "**"}, + }, + { + name: "lista con viñetas y negrita en cada línea", + in: "- **Factura ID:** 3\n- **Cliente:** DOCUXER S.A.S.", + wantContains: []string{"• Factura ID: 3", "• Cliente: DOCUXER S.A.S."}, + wantNotContains: []string{"- **"}, + }, + { + name: "separador horizontal se elimina", + in: "Texto antes\n---\nTexto después", + wantContains: []string{"Texto antes", "Texto después"}, + wantNotContains: []string{"---"}, + }, + { + name: "caracteres HTML literales se escapan", + in: "El monto es < 1000 y > 500 & sigue", + wantContains: []string{"< 1000", "> 500", "& sigue"}, + }, + { + name: "código en línea se preserva sin escapar dos veces", + in: "Usa el comando `git status` para ver el estado", + wantContains: []string{"git status"}, + }, + { + name: "bloque de código se convierte a pre", + in: "```\nfunc main() {}\n```", + wantContains: []string{"
func main() {}
"}, + }, + { + name: "enlace markdown", + in: "Mira [este link](https://u-site.app)", + wantContains: []string{`este link`}, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := FormatearParaTelegram(c.in) + for _, want := range c.wantContains { + if !strings.Contains(got, want) { + t.Errorf("esperaba que el resultado contuviera %q\nresultado: %q", want, got) + } + } + for _, notWant := range c.wantNotContains { + if strings.Contains(got, notWant) { + t.Errorf("no esperaba que el resultado contuviera %q\nresultado: %q", notWant, got) + } + } + }) + } +} diff --git a/rest/controllers/telegram_agent_controller.go b/rest/controllers/telegram_agent_controller.go index 566fc0b..3409c30 100644 --- a/rest/controllers/telegram_agent_controller.go +++ b/rest/controllers/telegram_agent_controller.go @@ -165,7 +165,7 @@ func TelegramAgentWebhook(c *fiber.Ctx) error { func sendAgentReply(botToken string, chatID int64, text string) error { svc := &services.TelegramService{BotToken: botToken} - return svc.SendMessage(chatID, text) + return svc.SendMessage(chatID, services.FormatearParaTelegram(text)) } // ─── CRUD de chats autorizados ────────────────────────────────────────────────