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 ────────────────────────────────────────────────