fix: convertir Markdown a HTML de Telegram antes de enviar mensajes del agente

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 <noreply@anthropic.com>
This commit is contained in:
Lizandro GD
2026-08-03 02:26:42 +00:00
co-authored by Claude Sonnet 5
parent a0b2e9ae0a
commit 7b0e4b9b5c
4 changed files with 155 additions and 3 deletions
+2 -2
View File
@@ -1396,9 +1396,9 @@ Comandos: /reset /instancias /ayuda`, nil
if !c.Activo {
estado = "✗ inactiva"
}
lines[i] = fmt.Sprintf("• #%d <b>%s</b> — %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 "<b>Instancias de Coolify:</b>\n" + strings.Join(lines, "\n"), nil
return "**Instancias de Coolify:**\n" + strings.Join(lines, "\n"), nil
}
// Cargar historial (últimos 20 mensajes)
+82
View File
@@ -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("<pre>" + escapeTelegramHTML(strings.TrimSpace(sub[1])) + "</pre>")
})
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
sub := reInlineCode.FindStringSubmatch(m)
return protect("<code>" + escapeTelegramHTML(sub[1]) + "</code>")
})
// 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 <b> 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 "<b>" + inner + "</b>"
})
// 5. Encabezados (#, ##, ###...) → negrita, sin duplicar <b> si ya la trae.
text = reHeader.ReplaceAllStringFunc(text, func(m string) string {
sub := reHeader.FindStringSubmatch(m)
inner := strings.NewReplacer("<b>", "", "</b>", "").Replace(sub[1])
return "<b>" + inner + "</b>"
})
// 6. Viñetas "- " o "* " al inicio de línea → "• ".
text = reBullet.ReplaceAllString(text, "$1• ")
// 7. Enlaces [texto](url).
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
// 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)
}
@@ -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{"<b>✅ ¡Factura registrada exitosamente!</b>"},
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{"• <b>Factura ID:</b> 3", "• <b>Cliente:</b> 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{"&lt; 1000", "&gt; 500", "&amp; 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{"<code>git status</code>"},
},
{
name: "bloque de código se convierte a pre",
in: "```\nfunc main() {}\n```",
wantContains: []string{"<pre>func main() {}</pre>"},
},
{
name: "enlace markdown",
in: "Mira [este link](https://u-site.app)",
wantContains: []string{`<a href="https://u-site.app">este link</a>`},
},
}
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)
}
}
})
}
}
@@ -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 ────────────────────────────────────────────────