feat: añadir sistema de comandos directos (!command)
Implementa pkg/command/ como core puro: tipos Spec/ParsedArgs, parser de args key=value con soporte de comillas, specs de 8 comandos built-in (help, tools, tool, ping, status, info, clear, version) y BuiltinNames() para aliases. En agents/runtime.go: nuevo flujo handleEvent que prioriza comandos sobre LLM — custom rules del agente → built-in handlers → comando desconocido → LLM fallback. Handlers en agents/commands.go. El comando !tool ejecuta tools directamente via Registry con args key=value parseados. LLM ahora es opcional: si no hay provider configurado, el agente corre como simple_bot respondiendo solo a comandos. Se extrae executeActions() como helper reutilizable para ambos flujos (comando y no-comando).
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package command
|
||||
|
||||
// Builtins returns the specs of all built-in commands. Pure.
|
||||
func Builtins() []Spec {
|
||||
return []Spec{
|
||||
{
|
||||
Name: "help",
|
||||
Aliases: []string{"h"},
|
||||
Description: "Lista comandos disponibles",
|
||||
Usage: "!help",
|
||||
},
|
||||
{
|
||||
Name: "tools",
|
||||
Description: "Lista tools registradas con descripcion",
|
||||
Usage: "!tools",
|
||||
},
|
||||
{
|
||||
Name: "tool",
|
||||
Description: "Ejecutar una tool directamente",
|
||||
Usage: "!tool <nombre> [key=value ...]",
|
||||
},
|
||||
{
|
||||
Name: "ping",
|
||||
Description: "Alive check",
|
||||
Usage: "!ping",
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Description: "Info del agente: uptime, rooms activos",
|
||||
Usage: "!status",
|
||||
},
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Nombre, version y descripcion del agente",
|
||||
Usage: "!info",
|
||||
},
|
||||
{
|
||||
Name: "clear",
|
||||
Description: "Limpia ventana de conversacion del room actual",
|
||||
Usage: "!clear",
|
||||
},
|
||||
{
|
||||
Name: "version",
|
||||
Aliases: []string{"v"},
|
||||
Description: "Version del agente",
|
||||
Usage: "!version",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuiltinNames returns just the command names (including aliases) for lookup. Pure.
|
||||
func BuiltinNames() map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, spec := range Builtins() {
|
||||
m[spec.Name] = spec.Name
|
||||
for _, alias := range spec.Aliases {
|
||||
m[alias] = spec.Name
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseArgs converts a slice of raw arguments into structured ParsedArgs.
|
||||
// Supports: positional args, key=value pairs, and quoted values like key="hello world".
|
||||
// Pure function — no side effects.
|
||||
func ParseArgs(args []string) ParsedArgs {
|
||||
p := ParsedArgs{
|
||||
Named: make(map[string]string),
|
||||
Raw: args,
|
||||
}
|
||||
|
||||
// First, rejoin args to handle quoted values that were split by Fields().
|
||||
joined := strings.Join(args, " ")
|
||||
tokens := tokenize(joined)
|
||||
|
||||
for _, tok := range tokens {
|
||||
if idx := strings.IndexByte(tok, '='); idx > 0 {
|
||||
key := tok[:idx]
|
||||
val := tok[idx+1:]
|
||||
// Strip surrounding quotes from value
|
||||
val = stripQuotes(val)
|
||||
p.Named[key] = val
|
||||
} else {
|
||||
p.Positional = append(p.Positional, tok)
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// ArgsToJSON converts a named args map to a JSON string for tools.Registry.Execute.
|
||||
// Pure function.
|
||||
func ArgsToJSON(named map[string]string) string {
|
||||
if len(named) == 0 {
|
||||
return ""
|
||||
}
|
||||
m := make(map[string]any, len(named))
|
||||
for k, v := range named {
|
||||
m[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// tokenize splits a string respecting quoted values.
|
||||
// e.g. `host=server1 command="uptime -a"` → ["host=server1", `command="uptime -a"`]
|
||||
func tokenize(s string) []string {
|
||||
var tokens []string
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
switch {
|
||||
case !inQuote && (ch == '"' || ch == '\''):
|
||||
inQuote = true
|
||||
quoteChar = ch
|
||||
current.WriteByte(ch)
|
||||
case inQuote && ch == quoteChar:
|
||||
inQuote = false
|
||||
current.WriteByte(ch)
|
||||
case !inQuote && ch == ' ':
|
||||
if current.Len() > 0 {
|
||||
tokens = append(tokens, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
tokens = append(tokens, current.String())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// stripQuotes removes surrounding double or single quotes from a string.
|
||||
func stripQuotes(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseArgs_Empty(t *testing.T) {
|
||||
p := ParseArgs(nil)
|
||||
if len(p.Positional) != 0 {
|
||||
t.Errorf("expected 0 positional, got %d", len(p.Positional))
|
||||
}
|
||||
if len(p.Named) != 0 {
|
||||
t.Errorf("expected 0 named, got %d", len(p.Named))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgs_Positional(t *testing.T) {
|
||||
p := ParseArgs([]string{"ssh_command"})
|
||||
if len(p.Positional) != 1 || p.Positional[0] != "ssh_command" {
|
||||
t.Errorf("expected [ssh_command], got %v", p.Positional)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgs_Named(t *testing.T) {
|
||||
p := ParseArgs([]string{"host=server1", "command=uptime"})
|
||||
if p.Named["host"] != "server1" {
|
||||
t.Errorf("expected host=server1, got %q", p.Named["host"])
|
||||
}
|
||||
if p.Named["command"] != "uptime" {
|
||||
t.Errorf("expected command=uptime, got %q", p.Named["command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgs_QuotedValue(t *testing.T) {
|
||||
p := ParseArgs([]string{`host=server1`, `command="uptime`, `-a"`})
|
||||
if p.Named["host"] != "server1" {
|
||||
t.Errorf("expected host=server1, got %q", p.Named["host"])
|
||||
}
|
||||
if p.Named["command"] != "uptime -a" {
|
||||
t.Errorf("expected command='uptime -a', got %q", p.Named["command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgs_Mixed(t *testing.T) {
|
||||
p := ParseArgs([]string{"ssh_command", "host=server1", "command=ls"})
|
||||
if len(p.Positional) != 1 || p.Positional[0] != "ssh_command" {
|
||||
t.Errorf("expected positional [ssh_command], got %v", p.Positional)
|
||||
}
|
||||
if p.Named["host"] != "server1" {
|
||||
t.Errorf("expected host=server1, got %q", p.Named["host"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgs_SingleQuotes(t *testing.T) {
|
||||
p := ParseArgs([]string{`query='hello`, `world'`})
|
||||
if p.Named["query"] != "hello world" {
|
||||
t.Errorf("expected query='hello world', got %q", p.Named["query"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgsToJSON_Empty(t *testing.T) {
|
||||
result := ArgsToJSON(nil)
|
||||
if result != "" {
|
||||
t.Errorf("expected empty string, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgsToJSON_Values(t *testing.T) {
|
||||
result := ArgsToJSON(map[string]string{"host": "server1", "command": "uptime"})
|
||||
if result == "" {
|
||||
t.Error("expected non-empty JSON")
|
||||
}
|
||||
// Should contain both keys
|
||||
if !contains(result, `"host"`) || !contains(result, `"server1"`) {
|
||||
t.Errorf("JSON missing expected keys: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package command defines pure types and functions for the bot command system.
|
||||
// Commands are direct actions triggered by !prefix messages (e.g. !help, !ping).
|
||||
package command
|
||||
|
||||
// Spec is the pure specification of a command. Only data, no side effects.
|
||||
type Spec struct {
|
||||
Name string
|
||||
Aliases []string // e.g. ["h"] for help
|
||||
Description string // short description for !help
|
||||
Usage string // e.g. "!tool <name> [key=value ...]"
|
||||
Hidden bool // do not show in !help
|
||||
}
|
||||
|
||||
// ParsedArgs is the result of parsing "key=value key2=value2" arguments.
|
||||
type ParsedArgs struct {
|
||||
Positional []string // args without key=
|
||||
Named map[string]string // args with key=value
|
||||
Raw []string // original args
|
||||
}
|
||||
Reference in New Issue
Block a user