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
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"
|
||||
"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
|
||||
|
||||
if p, ok := registry[command]; ok {
|
||||
if command != "help" {
|
||||
userLastCommandType[msg.User.Name] = command
|
||||
}
|
||||
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"
|
||||
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:<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
|
||||
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: <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