From 76ff9394d018eb274a422af986f1791b706740ce Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sat, 7 Mar 2026 15:46:07 +0000 Subject: [PATCH] feat: respuestas como reply de Matrix + presencia online/offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade soporte para que las respuestas de los bots sean replies nativos de Matrix (m.in_reply_to) en lugar de mensajes sueltos. Los clientes Matrix mostrarán el mensaje original citado. Cambios: - EventID en MessageContext para capturar el ID del evento entrante - InReplyTo en ReplyAction para indicar a qué evento responder - SendReplyMarkdown en el cliente Matrix (shell/matrix/client.go) - Runner usa SendReplyMarkdown cuando InReplyTo está presente - runtime.go pasa InReplyTo en todas las respuestas LLM y comandos - SetPresence online al arrancar, offline al apagar (graceful) No se tocan: herramientas, TUI, configuración de agentes. Co-Authored-By: Claude Opus 4.6 --- agents/runtime.go | 24 ++++++++++++++++++------ pkg/decision/types.go | 8 +++++--- shell/effects/runner.go | 8 +++++++- shell/matrix/client.go | 20 ++++++++++++++++++++ shell/matrix/listener.go | 1 + 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/agents/runtime.go b/agents/runtime.go index 5f92e4a..83da64f 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -285,6 +285,18 @@ func (a *Agent) Run(ctx context.Context) error { "tools", a.toolReg.Names(), ) + // Set presence to online + if err := a.matrix.SetPresence(ctx, event.PresenceOnline); err != nil { + a.logger.Warn("failed to set presence online", "err", err) + } + defer func() { + // Use background context since ctx is already cancelled at shutdown + offlineCtx := context.Background() + if err := a.matrix.SetPresence(offlineCtx, event.PresenceOffline); err != nil { + a.logger.Warn("failed to set presence offline", "err", err) + } + }() + // Start bus listener if connected to the orchestration bus if a.agentBus != nil { ch := a.agentBus.Subscribe(bus.AgentID(a.cfg.Agent.ID)) @@ -453,14 +465,14 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext, if handler, ok := a.commands[cmdName]; ok { a.logger.Info("command_executed", "command", cmdName) reply := handler(ctx, msgCtx) - _ = a.matrix.SendMarkdown(ctx, roomID, reply) + _ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID, reply) return } // Unknown command — never falls through to rules or LLM a.logger.Info("command_unknown", "command", msgCtx.Command) - _ = a.matrix.SendMarkdown(ctx, roomID, - fmt.Sprintf("Comando desconocido: !%s. Usa !help para ver comandos disponibles.", msgCtx.Command)) + _ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID, + fmt.Sprintf("Comando desconocido: `!%s`. Usa `!help` para ver comandos disponibles.", msgCtx.Command)) return } @@ -502,7 +514,7 @@ func (a *Agent) executeActions(ctx context.Context, roomID string, msgCtx decisi a.logger.Warn("LLM action requested but no LLM configured") expanded = append(expanded, decision.Action{ Kind: decision.ActionKindReply, - Reply: &decision.ReplyAction{Content: "Este bot no tiene LLM configurado."}, + Reply: &decision.ReplyAction{Content: "Este bot no tiene LLM configurado.", InReplyTo: msgCtx.EventID}, }) continue } @@ -518,12 +530,12 @@ func (a *Agent) executeActions(ctx context.Context, roomID string, msgCtx decisi a.logger.Error("llm error", "err", err) expanded = append(expanded, decision.Action{ Kind: decision.ActionKindReply, - Reply: &decision.ReplyAction{Content: "Sorry, I encountered an error."}, + Reply: &decision.ReplyAction{Content: "Sorry, I encountered an error.", InReplyTo: msgCtx.EventID}, }) } else { expanded = append(expanded, decision.Action{ Kind: decision.ActionKindReply, - Reply: &decision.ReplyAction{Content: reply}, + Reply: &decision.ReplyAction{Content: reply, InReplyTo: msgCtx.EventID}, }) // Memory: append assistant reply after LLM call diff --git a/pkg/decision/types.go b/pkg/decision/types.go index 81244b6..31895e5 100644 --- a/pkg/decision/types.go +++ b/pkg/decision/types.go @@ -9,6 +9,7 @@ type MessageContext struct { SenderID string SenderName string RoomID string + EventID string // Matrix event ID of the incoming message Content string Command string // parsed command name, e.g. "deploy" Args []string // parsed arguments @@ -47,9 +48,10 @@ type Action struct { } type ReplyAction struct { - Content string - ThreadID string // empty = new thread - Reaction string // optional Matrix reaction + Content string + ThreadID string // empty = new thread + InReplyTo string // Matrix event ID to reply to (m.in_reply_to) + Reaction string // optional Matrix reaction } type LLMAction struct { diff --git a/shell/effects/runner.go b/shell/effects/runner.go index 1ccb474..9b5f154 100644 --- a/shell/effects/runner.go +++ b/shell/effects/runner.go @@ -23,6 +23,7 @@ type Result struct { type MatrixSender interface { SendText(ctx context.Context, roomID, text string) error SendMarkdown(ctx context.Context, roomID, markdown string) error + SendReplyMarkdown(ctx context.Context, roomID, inReplyTo, markdown string) error SendTyping(ctx context.Context, roomID string, typing bool) error } @@ -66,7 +67,12 @@ func (r *Runner) executeOne(ctx context.Context, roomID string, a decision.Actio if a.Reply.ThreadID != "" { target = a.Reply.ThreadID } - err := r.matrix.SendMarkdown(ctx, target, a.Reply.Content) + var err error + if a.Reply.InReplyTo != "" { + err = r.matrix.SendReplyMarkdown(ctx, target, a.Reply.InReplyTo, a.Reply.Content) + } else { + err = r.matrix.SendMarkdown(ctx, target, a.Reply.Content) + } return Result{Action: a, Output: a.Reply.Content, Err: err} case decision.ActionKindSSH: diff --git a/shell/matrix/client.go b/shell/matrix/client.go index 88f1329..68229f4 100644 --- a/shell/matrix/client.go +++ b/shell/matrix/client.go @@ -305,6 +305,21 @@ func mdToHTML(md string) string { return buf.String() } +// SendReplyMarkdown sends a formatted message as a reply to a specific event. +// It sets m.in_reply_to so Matrix clients show the original message as a quote. +func (c *Client) SendReplyMarkdown(ctx context.Context, roomID, inReplyTo, markdown string) error { + html := mdToHTML(markdown) + content := event.MessageEventContent{ + MsgType: event.MsgText, + Body: markdown, + Format: event.FormatHTML, + FormattedBody: html, + RelatesTo: (&event.RelatesTo{}).SetReplyTo(id.EventID(inReplyTo)), + } + _, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) + return err +} + // SendReaction sends a reaction to an event. func (c *Client) SendReaction(ctx context.Context, roomID, eventID, reaction string) error { _, err := c.raw.SendReaction(ctx, id.RoomID(roomID), id.EventID(eventID), reaction) @@ -448,6 +463,11 @@ func truncateKey(key string) string { return key } +// SetPresence sets the bot's presence status (online, unavailable, offline). +func (c *Client) SetPresence(ctx context.Context, status event.Presence) error { + return c.raw.SetPresence(ctx, status) +} + // Raw returns the underlying mautrix.Client for advanced use. func (c *Client) Raw() *mautrix.Client { return c.raw diff --git a/shell/matrix/listener.go b/shell/matrix/listener.go index 672f240..80eedae 100644 --- a/shell/matrix/listener.go +++ b/shell/matrix/listener.go @@ -134,6 +134,7 @@ func (l *Listener) Run(ctx context.Context) error { isDM, opts, ) + msgCtx.EventID = evt.ID.String() l.logger.Debug("message parsed", "sender", msgCtx.SenderID,