feat: renderizar Markdown a HTML en mensajes Matrix con goldmark

Se reemplaza SendText por SendMarkdown en todos los puntos donde el agente
envía respuestas: runtime.go (comandos built-in y tareas orquestadas),
effects/runner.go (acciones Reply) y tools/matrix.go (matrix_send tool).

shell/matrix/client.go ahora usa goldmark para convertir Markdown a HTML real
en el campo FormattedBody del evento Matrix, cumpliendo con la spec de Matrix
para mensajes formateados. El Body conserva el markdown raw como fallback.

Se añade dependencia github.com/yuin/goldmark v1.7.16.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 02:21:06 +00:00
parent e2f14e7abb
commit 828eb175fe
6 changed files with 25 additions and 6 deletions
+3 -3
View File
@@ -391,7 +391,7 @@ func (a *Agent) handleTaskEvent(ctx context.Context, msg bus.AgentMessage) {
} }
// Send reply to Matrix room // Send reply to Matrix room
if sendErr := a.matrix.SendText(ctx, roomID, reply); sendErr != nil { if sendErr := a.matrix.SendMarkdown(ctx, roomID, reply); sendErr != nil {
a.logger.Error("failed to send orchestrated reply to Matrix", "err", sendErr) a.logger.Error("failed to send orchestrated reply to Matrix", "err", sendErr)
} }
@@ -453,13 +453,13 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext,
if handler, ok := a.commands[cmdName]; ok { if handler, ok := a.commands[cmdName]; ok {
a.logger.Info("command_executed", "command", cmdName) a.logger.Info("command_executed", "command", cmdName)
reply := handler(ctx, msgCtx) reply := handler(ctx, msgCtx)
_ = a.matrix.SendText(ctx, roomID, reply) _ = a.matrix.SendMarkdown(ctx, roomID, reply)
return return
} }
// Unknown command — never falls through to rules or LLM // Unknown command — never falls through to rules or LLM
a.logger.Info("command_unknown", "command", msgCtx.Command) a.logger.Info("command_unknown", "command", msgCtx.Command)
_ = a.matrix.SendText(ctx, roomID, _ = a.matrix.SendMarkdown(ctx, roomID,
fmt.Sprintf("Comando desconocido: !%s. Usa !help para ver comandos disponibles.", msgCtx.Command)) fmt.Sprintf("Comando desconocido: !%s. Usa !help para ver comandos disponibles.", msgCtx.Command))
return return
} }
+1
View File
@@ -51,6 +51,7 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
go.mau.fi/util v0.8.1 // indirect go.mau.fi/util v0.8.1 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.30.0 // indirect golang.org/x/net v0.30.0 // indirect
+2
View File
@@ -110,6 +110,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo= go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo=
go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc= go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+2 -1
View File
@@ -22,6 +22,7 @@ type Result struct {
// MatrixSender is satisfied by shell/matrix.Client. // MatrixSender is satisfied by shell/matrix.Client.
type MatrixSender interface { type MatrixSender interface {
SendText(ctx context.Context, roomID, text string) error SendText(ctx context.Context, roomID, text string) error
SendMarkdown(ctx context.Context, roomID, markdown string) error
SendTyping(ctx context.Context, roomID string, typing bool) error SendTyping(ctx context.Context, roomID string, typing bool) error
} }
@@ -65,7 +66,7 @@ func (r *Runner) executeOne(ctx context.Context, roomID string, a decision.Actio
if a.Reply.ThreadID != "" { if a.Reply.ThreadID != "" {
target = a.Reply.ThreadID target = a.Reply.ThreadID
} }
err := r.matrix.SendText(ctx, target, a.Reply.Content) err := r.matrix.SendMarkdown(ctx, target, a.Reply.Content)
return Result{Action: a, Output: a.Reply.Content, Err: err} return Result{Action: a, Output: a.Reply.Content, Err: err}
case decision.ActionKindSSH: case decision.ActionKindSSH:
+15 -1
View File
@@ -3,6 +3,7 @@ package matrix
import ( import (
"context" "context"
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@@ -12,6 +13,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/yuin/goldmark"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/crypto/cryptohelper"
@@ -269,17 +271,29 @@ func (c *Client) SendText(ctx context.Context, roomID, text string) error {
} }
// SendMarkdown sends a formatted (Markdown) message to a room. // SendMarkdown sends a formatted (Markdown) message to a room.
// Body contains the raw markdown (plaintext fallback per Matrix spec).
// FormattedBody contains the HTML rendered by goldmark.
func (c *Client) SendMarkdown(ctx context.Context, roomID, markdown string) error { func (c *Client) SendMarkdown(ctx context.Context, roomID, markdown string) error {
html := mdToHTML(markdown)
content := event.MessageEventContent{ content := event.MessageEventContent{
MsgType: event.MsgText, MsgType: event.MsgText,
Body: markdown, Body: markdown,
Format: event.FormatHTML, Format: event.FormatHTML,
FormattedBody: markdown, // mautrix can render markdown -> HTML if needed FormattedBody: html,
} }
_, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) _, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
return err return err
} }
// mdToHTML converts a Markdown string to HTML using goldmark.
func mdToHTML(md string) string {
var buf bytes.Buffer
if err := goldmark.Convert([]byte(md), &buf); err != nil {
return md // fallback to raw markdown on error
}
return buf.String()
}
// SendReaction sends a reaction to an event. // SendReaction sends a reaction to an event.
func (c *Client) SendReaction(ctx context.Context, roomID, eventID, reaction string) error { func (c *Client) SendReaction(ctx context.Context, roomID, eventID, reaction string) error {
_, err := c.raw.SendReaction(ctx, id.RoomID(roomID), id.EventID(eventID), reaction) _, err := c.raw.SendReaction(ctx, id.RoomID(roomID), id.EventID(eventID), reaction)
+2 -1
View File
@@ -9,6 +9,7 @@ import (
// Satisfied by shell/matrix.Client. // Satisfied by shell/matrix.Client.
type MatrixSender interface { type MatrixSender interface {
SendText(ctx context.Context, roomID, text string) error SendText(ctx context.Context, roomID, text string) error
SendMarkdown(ctx context.Context, roomID, markdown string) error
} }
// NewMatrixSend creates a matrix_send tool that sends a message to a Matrix room. // NewMatrixSend creates a matrix_send tool that sends a message to a Matrix room.
@@ -29,7 +30,7 @@ func NewMatrixSend(sender MatrixSender) Tool {
return Result{Err: fmt.Errorf("matrix_send: room_id and message are required")} return Result{Err: fmt.Errorf("matrix_send: room_id and message are required")}
} }
if err := sender.SendText(ctx, roomID, message); err != nil { if err := sender.SendMarkdown(ctx, roomID, message); err != nil {
return Result{Err: fmt.Errorf("matrix_send: %w", err)} return Result{Err: fmt.Errorf("matrix_send: %w", err)}
} }