421ab5c773
Cuando command_prefix es "" en el config, el parser trata el primer token del mensaje como nombre de comando sin requerir el prefijo !. Si el token empieza con !, se le quita igualmente para retrocompatibilidad. Cambios: - pkg/message/parse.go: modo sin prefijo en Parse() (puro, sin side effects) - agents/robot.go: mensaje "comando desconocido" y !help adaptados al prefijo - agents/handler.go: mensaje "comando desconocido" adaptado al prefijo - internal/config/schema.go: documentar command_prefix: "" en FiltersCfg - agents/_template_robot/config.yaml: ejemplo comentado de command_prefix: "" El comportamiento con command_prefix: "!" no cambia (retrocompatible).
300 lines
9.3 KiB
Go
300 lines
9.3 KiB
Go
package agents
|
|
|
|
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)
|
|
}
|