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 {
|
if !c.Activo {
|
||||||
estado = "✗ inactiva"
|
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)
|
// 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 {
|
func sendAgentReply(botToken string, chatID int64, text string) error {
|
||||||
svc := &services.TelegramService{BotToken: botToken}
|
svc := &services.TelegramService{BotToken: botToken}
|
||||||
return svc.SendMessage(chatID, text)
|
return svc.SendMessage(chatID, services.FormatearParaTelegram(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CRUD de chats autorizados ────────────────────────────────────────────────
|
// ─── CRUD de chats autorizados ────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user