package devagents import ( "context" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "time" "maunium.net/go/mautrix/event" "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/pkg/command" "github.com/enmanuel/agents/pkg/decision" "github.com/enmanuel/agents/shell/matrix" ) // Robot is a lightweight runtime for command-only bots. // Unlike Agent, it has no LLM, rules, memory, knowledge, skills, or tools. // It connects to Matrix and dispatches commands; non-command messages are ignored. type Robot struct { cfg *config.AgentConfig matrix *matrix.Client logger *slog.Logger // E2EE crypto store — non-nil when encryption is enabled; closed on shutdown. cryptoStore io.Closer // Lifecycle cancel context.CancelFunc done chan struct{} // Commands — handlers keyed by canonical name; aliases maps alias → canonical. commands map[string]CommandHandler cmdAliases map[string]string customSpecs []command.Spec startTime time.Time // Personality prefix for replies prefix string // Matrix listener listener *matrix.Listener } // NewRobot creates a lightweight command-only bot from its config and logger. // It initializes only the Matrix client, E2EE (if configured), and built-in commands. func NewRobot(cfg *config.AgentConfig, logger *slog.Logger) (*Robot, error) { matrixClient, err := matrix.New(cfg.Matrix) if err != nil { return nil, fmt.Errorf("matrix client: %w", err) } // E2EE — initialize before the sync loop starts var cryptoStore io.Closer if cfg.Matrix.Encryption.Enabled { storePath := filepath.Join(cfg.Matrix.Encryption.StorePath, "crypto.db") pickleKey := os.Getenv(cfg.Matrix.Encryption.PickleKeyEnv) logger.Info("initializing e2ee", "store", storePath) cryptoStore, err = matrixClient.InitCrypto(context.Background(), storePath, pickleKey, cfg.Agent.ID) if err != nil { return nil, fmt.Errorf("e2ee init: %w", err) } // Auto-fetch cross-signing private keys from SSSS if recovery key is configured. if envName := cfg.Matrix.Encryption.RecoveryKeyEnv; envName != "" { if rk := os.Getenv(envName); rk != "" { if err := matrixClient.FetchCrossSigningKeys(context.Background(), rk); err != nil { logger.Warn("failed to fetch cross-signing keys from SSSS (non-fatal)", "err", err) } else { logger.Info("cross-signing private keys fetched from SSSS") } } } // Sign own device with the self-signing key so Element shows it as verified. if err := matrixClient.SignOwnDevice(context.Background()); err != nil { logger.Warn("failed to sign own device (non-fatal)", "err", err) } else { logger.Info("own device signed with cross-signing key") } logger.Info("e2ee ready") } r := &Robot{ cfg: cfg, matrix: matrixClient, logger: logger, cryptoStore: cryptoStore, done: make(chan struct{}), commands: make(map[string]CommandHandler), cmdAliases: command.BuiltinNames(), startTime: time.Now(), prefix: cfg.Personality.Prefix, } // Register built-in commands (robot-appropriate subset). r.registerBuiltinCommands() // Matrix event listener r.listener = matrix.NewListener(matrixClient, cfg.Matrix, r.handleEvent, logger) return r, nil } // registerBuiltinCommands registers command handlers appropriate for a robot. // Robots support: help, ping, status, info, version. // They do NOT support: tools, tool, clear, prompts (no LLM, no memory, no tools). func (r *Robot) registerBuiltinCommands() { r.commands["help"] = r.cmdHelp r.commands["ping"] = r.cmdPing r.commands["status"] = r.cmdStatus r.commands["info"] = r.cmdInfo r.commands["version"] = r.cmdVersion } // RegisterCommand adds a custom command handler for this robot. func (r *Robot) RegisterCommand(spec command.Spec, handler CommandHandler) { r.commands[spec.Name] = handler r.cmdAliases[spec.Name] = spec.Name for _, alias := range spec.Aliases { r.cmdAliases[alias] = spec.Name } r.customSpecs = append(r.customSpecs, spec) r.logger.Info("command_registered", "command", spec.Name, "aliases", spec.Aliases) } // Run starts the robot sync loop. Blocks until ctx is cancelled. func (r *Robot) Run(ctx context.Context) error { ctx, r.cancel = context.WithCancel(ctx) defer close(r.done) if r.cryptoStore != nil { defer r.cryptoStore.Close() } r.logger.Info("robot starting", "id", r.cfg.Agent.ID, "name", r.cfg.Agent.Name, "type", "robot", ) // Set presence to online if err := r.matrix.SetPresence(ctx, event.PresenceOnline); err != nil { r.logger.Warn("failed to set presence online", "err", err) } defer func() { offlineCtx := context.Background() if err := r.matrix.SetPresence(offlineCtx, event.PresenceOffline); err != nil { r.logger.Warn("failed to set presence offline", "err", err) } }() return r.listener.Run(ctx) } // Stop cancels this robot's individual context, causing Run to return. func (r *Robot) Stop() { if r.cancel != nil { r.cancel() } } // Done returns a channel that is closed when Run has returned. func (r *Robot) Done() <-chan struct{} { return r.done } // handleEvent is called by the matrix Listener for each filtered incoming event. // For a robot, only commands are processed; all other messages are silently ignored. func (r *Robot) handleEvent(ctx context.Context, msgCtx decision.MessageContext, evt *event.Event) { roomID := evt.RoomID.String() // Only process commands. Non-command messages are silently ignored. if msgCtx.Command == "" { r.logger.Debug("non-command message, ignoring (robot)", "sender", msgCtx.SenderID, "room", roomID, ) return } r.logger.Info("command_received", "command", msgCtx.Command, "sender", msgCtx.SenderID, "room", roomID, "args", msgCtx.Args, ) // Resolve aliases cmdName := msgCtx.Command if canonical, ok := r.cmdAliases[cmdName]; ok { cmdName = canonical } if handler, ok := r.commands[cmdName]; ok { r.logger.Info("command_executed", "command", cmdName) reply := handler(ctx, msgCtx) _ = r.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID, reply) return } // Unknown command r.logger.Info("command_unknown", "command", msgCtx.Command) unknownMsg := fmt.Sprintf("Comando desconocido: `!%s`. Usa `!help` para ver comandos disponibles.", msgCtx.Command) if r.cfg.Matrix.Filters.CommandPrefix == "" { unknownMsg = fmt.Sprintf("Comando desconocido: `%s`. Usa `help` para ver comandos disponibles.", msgCtx.Command) } _ = r.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID, unknownMsg) } // sendReply sends a markdown reply that respects thread context. func (r *Robot) sendReply(ctx context.Context, roomID, eventID, threadID, markdown string) error { if threadID != "" { return r.matrix.SendThreadMarkdown(ctx, roomID, threadID, eventID, markdown) } return r.matrix.SendReplyMarkdown(ctx, roomID, eventID, markdown) } // ── Built-in command handlers (robot subset) ───────────────────────────── func (r *Robot) cmdHelp(_ context.Context, _ decision.MessageContext) string { var b strings.Builder b.WriteString("**Comandos disponibles:**\n\n") prefix := r.cfg.Matrix.Filters.CommandPrefix // "!" or "" // Built-in commands appropriate for robots robotBuiltins := []command.Spec{ {Name: "help", Aliases: []string{"h"}, Description: "Lista comandos disponibles", Usage: prefix + "help"}, {Name: "ping", Description: "Alive check", Usage: prefix + "ping"}, {Name: "status", Description: "Info del robot: uptime", Usage: prefix + "status"}, {Name: "info", Description: "Nombre, version y descripcion", Usage: prefix + "info"}, {Name: "version", Aliases: []string{"v"}, Description: "Version del robot", Usage: prefix + "version"}, } for _, spec := range robotBuiltins { writeSpec(&b, spec) } // Agent-specific commands (registered via RegisterCommand) if len(r.customSpecs) > 0 { b.WriteString("\n**Comandos del robot:**\n\n") for _, spec := range r.customSpecs { if spec.Hidden { continue } writeSpec(&b, spec) } } return b.String() } func (r *Robot) cmdPing(_ context.Context, _ decision.MessageContext) string { return fmt.Sprintf("pong — %s", time.Now().Format(time.RFC3339)) } func (r *Robot) cmdStatus(_ context.Context, _ decision.MessageContext) string { uptime := time.Since(r.startTime).Truncate(time.Second) var b strings.Builder fmt.Fprintf(&b, "**Estado de %s:**\n\n", r.cfg.Agent.Name) fmt.Fprintf(&b, "- **Tipo:** robot\n") fmt.Fprintf(&b, "- **Uptime:** %s\n", uptime) fmt.Fprintf(&b, "- **Comandos custom:** %d\n", len(r.customSpecs)) return b.String() } func (r *Robot) cmdInfo(_ context.Context, _ decision.MessageContext) string { var b strings.Builder b.WriteString("## Identidad\n\n") fmt.Fprintf(&b, "- **Nombre:** %s\n", r.cfg.Agent.Name) fmt.Fprintf(&b, "- **ID:** `%s`\n", r.cfg.Agent.ID) fmt.Fprintf(&b, "- **Tipo:** robot\n") if r.cfg.Agent.Version != "" { fmt.Fprintf(&b, "- **Version:** %s\n", r.cfg.Agent.Version) } fmt.Fprintf(&b, "- **Descripcion:** %s\n", r.cfg.Agent.Description) uptime := time.Since(r.startTime).Round(time.Second) b.WriteString("\n## Uptime\n\n") fmt.Fprintf(&b, "- **Activo desde:** %s\n", uptime) return b.String() } func (r *Robot) cmdVersion(_ context.Context, _ decision.MessageContext) string { v := r.cfg.Agent.Version if v == "" { v = "sin version" } return fmt.Sprintf("%s %s", r.cfg.Agent.Name, v) }