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 <noreply@anthropic.com>
This commit is contained in:
Waldo 2026-03-06 21:27:53 -07:00
parent 1758961a66
commit 0b2e83ba0c
9 changed files with 307 additions and 111 deletions

View File

@ -1,3 +1,6 @@
build: build:
go build -o bin/ . go build -o bin/ .
test:
go test -v ./...

36
cmd_help.go Normal file
View File

@ -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)
}

12
cmd_hi.go Normal file
View File

@ -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?"
}

30
cmd_math.go Normal file
View File

@ -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 <expression>\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()
}

12
cmd_ping.go Normal file
View File

@ -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!"
}

52
cmd_trace.go Normal file
View File

@ -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 <url>\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:<br><br>\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: <strong>%s</strong><br>\n", label, dur)
}

133
main.go
View File

@ -4,13 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec"
"bytes"
// "log"
"time"
"strings"
) )
type BotMessage struct { type BotMessage struct {
@ -35,28 +29,26 @@ type BotMessage struct {
var userLastCommandType = make(map[string]string) var userLastCommandType = make(map[string]string)
func init() {
registerPlugin(PingPlugin{})
registerPlugin(MathPlugin{})
registerPlugin(HiPlugin{})
registerPlugin(TracePlugin{})
registerPlugin(HelpPlugin{})
}
func main() { func main() {
fmt.Println("Sox started") fmt.Println("Sox started")
http.HandleFunc("/", commandHandler) http.HandleFunc("/", commandHandler)
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {
port = "8096" port = "4567"
} }
panic(http.ListenAndServe(":"+port, nil)) 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) { func commandHandler(w http.ResponseWriter, r *http.Request) {
var msg BotMessage var msg BotMessage
err := json.NewDecoder(r.Body).Decode(&msg) err := json.NewDecoder(r.Body).Decode(&msg)
@ -65,111 +57,30 @@ func commandHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
fmt.Fprintln(w, executeCommand(msg, "-999nopreviouscommand", "")) fmt.Fprintln(w, executeCommand(msg))
return
} }
func executeCommand(msg BotMessage, c string, b string)(string) { func executeCommand(msg BotMessage) string {
fmt.Println("Last command: " + c)
command, arguments := SplitFirstWord(msg.Message.Body.Plain) 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){ if p, ok := registry[command]; ok {
cmd := exec.Command("qalc", s) if command != "help" {
// cmd.Stdin = strings.NewReader("and old falcon") userLastCommandType[msg.User.Name] = command
var out bytes.Buffer }
cmd.Stdout = &out return p.Execute(msg, arguments)
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"
} }
response, err := timeRequest(username, uri) // Fall back to last command, treating full message as new args
if err != nil { if last, ok := userLastCommandType[msg.User.Name]; ok {
// fmt.Fprintf(w, "Failed to time the request (%s)", err) if p, ok := registry[last]; ok {
// return return p.Execute(msg, msg.Message.Body.Plain)
return "Failed to time the request " //+ err }
} }
// fmt.Fprintln(w, response) return "Not sure what to do... Try `help` to see what I can do!"
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:<br><br>\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 errorResponse(w http.ResponseWriter, status int, msg string) { func errorResponse(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status) w.WriteHeader(status)
fmt.Fprintf(w, "Error: %s", msg) 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: <strong>%s</strong><br>\n", label, dur)
}

26
plugin.go Normal file
View File

@ -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
}

114
sox_test.go Normal file
View File

@ -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)
}
}