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:
parent
1758961a66
commit
0b2e83ba0c
3
Makefile
3
Makefile
@ -1,3 +1,6 @@
|
|||||||
build:
|
build:
|
||||||
go build -o bin/ .
|
go build -o bin/ .
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
|||||||
36
cmd_help.go
Normal file
36
cmd_help.go
Normal 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
12
cmd_hi.go
Normal 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
30
cmd_math.go
Normal 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
12
cmd_ping.go
Normal 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
52
cmd_trace.go
Normal 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
133
main.go
@ -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
26
plugin.go
Normal 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
114
sox_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user