From 0b2e83ba0ce44e6057dffe546e3f51332c5fd0bd Mon Sep 17 00:00:00 2001 From: Waldo <099waldo@gmail.com> Date: Fri, 6 Mar 2026 21:27:53 -0700 Subject: [PATCH] Fix test isolation bug and refactor command handling into plugins Use unique user in unknown command test to avoid last-command fallback interference. Add plugin architecture with registered commands. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 3 ++ cmd_help.go | 36 ++++++++++++++ cmd_hi.go | 12 +++++ cmd_math.go | 30 ++++++++++++ cmd_ping.go | 12 +++++ cmd_trace.go | 52 ++++++++++++++++++++ main.go | 133 +++++++++------------------------------------------ plugin.go | 26 ++++++++++ sox_test.go | 114 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 307 insertions(+), 111 deletions(-) create mode 100644 cmd_help.go create mode 100644 cmd_hi.go create mode 100644 cmd_math.go create mode 100644 cmd_ping.go create mode 100644 cmd_trace.go create mode 100644 plugin.go create mode 100644 sox_test.go diff --git a/Makefile b/Makefile index d2a102d..a9f10db 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ build: go build -o bin/ . +test: + go test -v ./... + diff --git a/cmd_help.go b/cmd_help.go new file mode 100644 index 0000000..e4d9d3f --- /dev/null +++ b/cmd_help.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "sort" + "strings" +) + +type HelpPlugin struct{} + +func (p HelpPlugin) Name() string { return "help" } +func (p HelpPlugin) ShortHelp() string { return "List commands or get help for a specific command" } +func (p HelpPlugin) DetailedHelp() string { + return "help [command]\n\nWith no arguments, lists all available commands.\nWith a command name, shows detailed help for that command.\n\nExamples:\n help\n help math" +} +func (p HelpPlugin) Execute(msg BotMessage, args string) string { + if args == "" { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + + var sb strings.Builder + for _, name := range names { + fmt.Fprintf(&sb, "%s — %s\n", name, registry[name].ShortHelp()) + } + return sb.String() + } + + if plugin, ok := registry[args]; ok { + return plugin.DetailedHelp() + } + + return fmt.Sprintf("No help found for '%s'. Try 'help' to see all commands.", args) +} diff --git a/cmd_hi.go b/cmd_hi.go new file mode 100644 index 0000000..0074c18 --- /dev/null +++ b/cmd_hi.go @@ -0,0 +1,12 @@ +package main + +type HiPlugin struct{} + +func (p HiPlugin) Name() string { return "hi" } +func (p HiPlugin) ShortHelp() string { return "Get a friendly greeting" } +func (p HiPlugin) DetailedHelp() string { + return "hi\n\nSays hello back to you by name." +} +func (p HiPlugin) Execute(msg BotMessage, args string) string { + return "Hello " + msg.User.Name + "! How are you doing today?" +} diff --git a/cmd_math.go b/cmd_math.go new file mode 100644 index 0000000..5b134de --- /dev/null +++ b/cmd_math.go @@ -0,0 +1,30 @@ +package main + +import ( + "bytes" + "fmt" + "os/exec" +) + +type MathPlugin struct{} + +func (p MathPlugin) Name() string { return "math" } +func (p MathPlugin) ShortHelp() string { return "Evaluate a math expression" } +func (p MathPlugin) DetailedHelp() string { + return "math \n\nEvaluates a mathematical expression using qalc (libqalculate).\n\nExamples:\n math 2+2\n math 100 * 9.5%\n math sqrt(144)" +} +func (p MathPlugin) Execute(msg BotMessage, args string) string { + return runMath(args) +} + +func runMath(s string) string { + cmd := exec.Command("qalc", s) + var out bytes.Buffer + cmd.Stdout = &out + + err := cmd.Run() + if err != nil { + fmt.Println(err) + } + return out.String() +} diff --git a/cmd_ping.go b/cmd_ping.go new file mode 100644 index 0000000..394c475 --- /dev/null +++ b/cmd_ping.go @@ -0,0 +1,12 @@ +package main + +type PingPlugin struct{} + +func (p PingPlugin) Name() string { return "ping" } +func (p PingPlugin) ShortHelp() string { return "Check if I'm alive" } +func (p PingPlugin) DetailedHelp() string { + return "ping\n\nResponds with 'Pong!' to confirm the bot is running." +} +func (p PingPlugin) Execute(msg BotMessage, args string) string { + return "Pong!" +} diff --git a/cmd_trace.go b/cmd_trace.go new file mode 100644 index 0000000..0221832 --- /dev/null +++ b/cmd_trace.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "net/url" + "time" +) + +type TracePlugin struct{} + +func (p TracePlugin) Name() string { return "trace" } +func (p TracePlugin) ShortHelp() string { return "Time an HTTP request" } +func (p TracePlugin) DetailedHelp() string { + return "trace \n\nTimes an HTTP/HTTPS request to the given URL and reports DNS lookup, connect, TLS negotiation, time to first byte, and time to last byte.\n\nExample:\n trace https://example.com" +} +func (p TracePlugin) Execute(msg BotMessage, args string) string { + uri, err := url.Parse(args) + if err != nil || (uri.Scheme != "http" && uri.Scheme != "https") { + return "That doesn't look like a valid URL for me to call" + } + + response, err := timeRequest(msg.User.Name, uri) + if err != nil { + return "Failed to time the request" + } + return response +} + +func timeRequest(username string, uri *url.URL) (string, error) { + trace, err := TraceRequest(uri) + if err != nil { + return "", err + } + + result := fmt.Sprintf("Hi %s! I've checked %s for you, and here's what I found:

\n", username, uri) + result += formatDuration("DNS lookup", trace.DNSStart, trace.DNSDone) + result += formatDuration("Connect", trace.ConnectStart, trace.ConnectDone) + result += formatDuration("TLS negotiation", trace.TLSStart, trace.TLSDone) + result += formatDuration("Sending headers", trace.ConnectionReady(), trace.WroteHeaders) + result += formatDuration("Time to first byte", trace.WroteHeaders, trace.FirstByte) + result += formatDuration("Time to last byte", trace.WroteHeaders, trace.AllDone) + + return result, nil +} + +func formatDuration(label string, start, end time.Time) string { + dur := end.Sub(start).String() + if start.IsZero() || end.IsZero() { + dur = "n/a" + } + return fmt.Sprintf("%s: %s
\n", label, dur) +} diff --git a/main.go b/main.go index 8544eb0..3bbe436 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,7 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "os" - "os/exec" - "bytes" - // "log" - "time" - "strings" ) type BotMessage struct { @@ -35,28 +29,26 @@ type BotMessage struct { var userLastCommandType = make(map[string]string) +func init() { + registerPlugin(PingPlugin{}) + registerPlugin(MathPlugin{}) + registerPlugin(HiPlugin{}) + registerPlugin(TracePlugin{}) + registerPlugin(HelpPlugin{}) +} + func main() { fmt.Println("Sox started") http.HandleFunc("/", commandHandler) port := os.Getenv("PORT") if port == "" { - port = "8096" + port = "4567" } panic(http.ListenAndServe(":"+port, nil)) } -func SplitFirstWord(input string) (firstWord string, restOfString string) { - words := strings.Fields(input) - if len(words) == 0 { - return "", "" - } - firstWord = words[0] - restOfString = strings.Join(words[1:], " ") - return strings.ToLower(firstWord), restOfString -} - func commandHandler(w http.ResponseWriter, r *http.Request) { var msg BotMessage err := json.NewDecoder(r.Body).Decode(&msg) @@ -65,111 +57,30 @@ func commandHandler(w http.ResponseWriter, r *http.Request) { return } - fmt.Fprintln(w, executeCommand(msg, "-999nopreviouscommand", "")) - - return + fmt.Fprintln(w, executeCommand(msg)) } -func executeCommand(msg BotMessage, c string, b string)(string) { - fmt.Println("Last command: " + c) +func executeCommand(msg BotMessage) string { command, arguments := SplitFirstWord(msg.Message.Body.Plain) - // value, exists := userLastCommandType[msg.User.Name] - if c != "-999nopreviouscommand" { - command = c - } - switch command { - case "ping": - fmt.Println("ping command received") - userLastCommandType[msg.User.Name] = command; - return "Pong!" - // case "trace": - // fmt.Println("trace command received") - // var j = traceHandler(w, r, arguments, msg.User.Name) - // return j - case "math": - userLastCommandType[msg.User.Name] = command; - return runMath(b+arguments) - case "hi": - userLastCommandType[msg.User.Name] = command; - return "Hello " + msg.User.Name + "! How are you doing today?" - default: - value, exists := userLastCommandType[msg.User.Name] - if exists { - return executeCommand(msg, value, command) - } else { - c = "-999invalidcommand" - return "Command is: " + command + " arguments are: " + arguments + ". Not sure what to do..." - } - } - // if c != "-999invalidcommand" { - // userLastCommandType[msg.User.Name] = command; - // } - return "error" -} -func runMath(s string)(string){ - cmd := exec.Command("qalc", s) - // cmd.Stdin = strings.NewReader("and old falcon") - var out bytes.Buffer - cmd.Stdout = &out - - err := cmd.Run() - - if err != nil { - // log.Warn(err) - fmt.Println(err) - } - return out.String() - // fmt.Printf("translated phrase: %q\n", out.String()) -} - - -func traceHandler(w http.ResponseWriter, r *http.Request, urlstring string, username string)(string) { - var msg BotMessage - err := json.NewDecoder(r.Body).Decode(&msg) - - uri, err := url.Parse(urlstring) - if err != nil || (uri.Scheme != "http" && uri.Scheme != "https") { - return "That doesn't look like a valid URL for to me to call" + if p, ok := registry[command]; ok { + if command != "help" { + userLastCommandType[msg.User.Name] = command + } + return p.Execute(msg, arguments) } - response, err := timeRequest(username, uri) - if err != nil { - // fmt.Fprintf(w, "Failed to time the request (%s)", err) - // return - return "Failed to time the request " //+ err + // Fall back to last command, treating full message as new args + if last, ok := userLastCommandType[msg.User.Name]; ok { + if p, ok := registry[last]; ok { + return p.Execute(msg, msg.Message.Body.Plain) + } } - // fmt.Fprintln(w, response) - return response -} - -func timeRequest(username string, uri *url.URL) (string, error) { - trace, err := TraceRequest(uri) - if err != nil { - return "", err - } - - result := fmt.Sprintf("Hi %s! I've checked %s for you, and here's what I found:

\n", username, uri) - result += formatDuration("DNS lookup", trace.DNSStart, trace.DNSDone) - result += formatDuration("Connect", trace.ConnectStart, trace.ConnectDone) - result += formatDuration("TLS negotiation", trace.TLSStart, trace.TLSDone) - result += formatDuration("Sending headers", trace.ConnectionReady(), trace.WroteHeaders) - result += formatDuration("Time to first byte", trace.WroteHeaders, trace.FirstByte) - result += formatDuration("Time to last byte", trace.WroteHeaders, trace.AllDone) - - return result, nil + return "Not sure what to do... Try `help` to see what I can do!" } func errorResponse(w http.ResponseWriter, status int, msg string) { w.WriteHeader(status) fmt.Fprintf(w, "Error: %s", msg) } - -func formatDuration(label string, start, end time.Time) string { - dur := end.Sub(start).String() - if start.IsZero() || end.IsZero() { - dur = "n/a" - } - return fmt.Sprintf("%s: %s
\n", label, dur) -} diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..0ca2e3e --- /dev/null +++ b/plugin.go @@ -0,0 +1,26 @@ +package main + +import "strings" + +type Plugin interface { + Name() string + ShortHelp() string + DetailedHelp() string + Execute(msg BotMessage, args string) string +} + +var registry = map[string]Plugin{} + +func registerPlugin(p Plugin) { + registry[p.Name()] = p +} + +func SplitFirstWord(input string) (firstWord string, restOfString string) { + words := strings.Fields(input) + if len(words) == 0 { + return "", "" + } + firstWord = words[0] + restOfString = strings.Join(words[1:], " ") + return strings.ToLower(firstWord), restOfString +} diff --git a/sox_test.go b/sox_test.go new file mode 100644 index 0000000..56b652e --- /dev/null +++ b/sox_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "os/exec" + "strings" + "testing" +) + +func makeMsg(user, body string) BotMessage { + var msg BotMessage + msg.User.Name = user + msg.Message.Body.Plain = body + return msg +} + +func qalcAvailable() bool { + _, err := exec.LookPath("qalc") + return err == nil +} + +func TestExecuteCommand(t *testing.T) { + tests := []struct { + user string + body string + exact string + contains []string + }{ + {user: "anyone", body: "ping", exact: "Pong!"}, + {user: "Alice", body: "hi", exact: "Hello Alice! How are you doing today?"}, + {user: "anyone", body: "help math", contains: []string{"qalc"}}, + {user: "anyone", body: "help bogus", contains: []string{"No help found for 'bogus'"}}, + {user: "anyone", body: "help", contains: []string{"ping", "math", "hi", "trace", "help"}}, + {user: "nobody", body: "unknown command", contains: []string{"Not sure what to do"}}, + } + + for _, tt := range tests { + t.Run(tt.body, func(t *testing.T) { + got := executeCommand(makeMsg(tt.user, tt.body)) + if tt.exact != "" && got != tt.exact { + t.Errorf("got %q, want %q", got, tt.exact) + } + for _, sub := range tt.contains { + if !strings.Contains(got, sub) { + t.Errorf("output %q does not contain %q", got, sub) + } + } + }) + } +} + +func TestLastCommandMemoryPing(t *testing.T) { + user := "testmem-ping-fallback" + got := executeCommand(makeMsg(user, "ping")) + if got != "Pong!" { + t.Fatalf("expected Pong!, got %q", got) + } + + got = executeCommand(makeMsg(user, "anything")) + if got != "Pong!" { + t.Errorf("expected fallback to ping, got %q", got) + } +} + +func TestLastCommandMemoryMath(t *testing.T) { + if !qalcAvailable() { + t.Skip("qalc not on PATH") + } + + user := "testmem-math-fallback" + got := executeCommand(makeMsg(user, "math 2+2")) + if !strings.Contains(got, "4") { + t.Fatalf("math 2+2 expected output containing '4', got %q", got) + } + + got = executeCommand(makeMsg(user, "3+3")) + if !strings.Contains(got, "6") { + t.Errorf("fallback math 3+3 expected output containing '6', got %q", got) + } +} + +func TestSplitFirstWord(t *testing.T) { + tests := []struct { + input string + wantWord string + wantRest string + }{ + {"ping", "ping", ""}, + {"math 2+2", "math", "2+2"}, + {"MATH 2+2", "math", "2+2"}, + {"", "", ""}, + {" ping ", "ping", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + word, rest := SplitFirstWord(tt.input) + if word != tt.wantWord || rest != tt.wantRest { + t.Errorf("SplitFirstWord(%q) = (%q, %q), want (%q, %q)", + tt.input, word, rest, tt.wantWord, tt.wantRest) + } + }) + } +} + +func TestMath(t *testing.T) { + if !qalcAvailable() { + t.Skip("qalc not on PATH") + } + + got := executeCommand(makeMsg("anyone", "math 2+2")) + if !strings.Contains(got, "4") { + t.Errorf("math 2+2 expected output containing '4', got %q", got) + } +}