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:
co-authored by
Claude Sonnet 5
parent
a0b2e9ae0a
commit
7b0e4b9b5c
@@ -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)
|
||||
|
||||
@@ -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{"< 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{"<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 ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user