From 5e84ebf0b7d2efb1d4435e415a950ad72436a85d Mon Sep 17 00:00:00 2001 From: Waldo <099waldo@gmail.com> Date: Sat, 17 Jan 2026 11:22:16 -0700 Subject: [PATCH] initial commit --- .gitignore | 1 + Makefile | 3 + README.md | 14 +++++ go.mod | 3 + main.go | 149 +++++++++++++++++++++++++++++++++++++++++++++++ package.nix | 23 ++++++++ request_trace.go | 72 +++++++++++++++++++++++ 7 files changed, 265 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 main.go create mode 100644 package.nix create mode 100644 request_trace.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e56e04 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/bin diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d2a102d --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +build: + go build -o bin/ . + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4ffe11 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Go bot example + +This directory contains an example of a simple Campfire bot written in Go. + +This bot implments a single endpoint, `/trace`. You can message this endpoint +with a URL. The bot will make a `GET` request to that URL, and respond with some +timings about how long parts of that request took: DNS lookup, time to first +byte, and so on. + +The functionality of the bot is basic. But this example shows how you can: + +- Start an HTTP service to listen on a bot endpoint +- Parse the JSON from the message request +- Respond to that request with some HTML-formatted text diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bf83753 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module sox + +go 1.22.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..c968bed --- /dev/null +++ b/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "bytes" + // "log" + "time" + "strings" +) + +type BotMessage struct { + User struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"user"` + + Room struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"room"` + + Message struct { + ID int `json:"id"` + Body struct { + HTML string `json:"html"` + Plain string `json:"plain"` + } `json:"body"` + } `json:"message"` +} + +func main() { + http.HandleFunc("/", commandHandler) + + port := os.Getenv("PORT") + if port == "" { + port = "8096" + } + + 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) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid request body") + return + } + + command, arguments := SplitFirstWord(msg.Message.Body.Plain) + switch command { + case "ping": + fmt.Println("ping command received") + fmt.Fprintln(w, "Pong!") + case "trace": + fmt.Println("trace command received") + var j = traceHandler(w, r, arguments, msg.User.Name) + fmt.Fprintln(w, j) + case "math": + fmt.Fprintln(w, runMath(arguments)) + case "hi": + fmt.Fprintln(w, "Hello " + msg.User.Name + "! How are you doing today?") + default: + fmt.Fprintln(w, "Command is: " + command + " arguments are: " + arguments + ". Not sure what to do...") + } + return +} + +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" + } + + 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 + } + + // 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 +} + +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/package.nix b/package.nix new file mode 100644 index 0000000..d06fb6b --- /dev/null +++ b/package.nix @@ -0,0 +1,23 @@ +{ pkgs, lib }: + +pkgs.stdenv.mkDerivation { + pname = "sox"; + version = "1.0"; + + src = ./.; + + buildInputs = [ pkgs.go pkgs.libqalculate ]; + + buildPhase = '' + # Nothing to build for a static site + # cp -r $src/* $out/var/www/my-static-site/ + mkdir -p $out/bin + go build -o $out/bin/ $src/ + ''; + + installPhase = '' + chmod +x $out/bin/sox + ''; +} + + diff --git a/request_trace.go b/request_trace.go new file mode 100644 index 0000000..bcc15bc --- /dev/null +++ b/request_trace.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "crypto/tls" + "io" + "net/http" + "net/http/httptrace" + "net/url" + "time" +) + +type RequestTrace struct { + Started time.Time + DNSStart time.Time + DNSDone time.Time + ConnectStart time.Time + ConnectDone time.Time + TLSStart time.Time + TLSDone time.Time + WroteHeaders time.Time + FirstByte time.Time + AllDone time.Time +} + +func (t RequestTrace) ConnectionReady() time.Time { + if t.TLSDone.After(t.ConnectDone) { + return t.TLSDone + } + return t.ConnectDone +} + +func TraceRequest(uri *url.URL) (RequestTrace, error) { + var trace RequestTrace + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { trace.Started = time.Now() }, + DNSStart: func(info httptrace.DNSStartInfo) { trace.DNSStart = time.Now() }, + DNSDone: func(info httptrace.DNSDoneInfo) { trace.DNSDone = time.Now() }, + ConnectStart: func(network, addr string) { trace.ConnectStart = time.Now() }, + ConnectDone: func(network, addr string, err error) { trace.ConnectDone = time.Now() }, + TLSHandshakeStart: func() { trace.TLSStart = time.Now() }, + TLSHandshakeDone: func(tls.ConnectionState, error) { trace.TLSDone = time.Now() }, + WroteHeaders: func() { trace.WroteHeaders = time.Now() }, + GotFirstResponseByte: func() { trace.FirstByte = time.Now() }, + }) + + req, err := http.NewRequest("GET", uri.String(), nil) + req = req.WithContext(ctx) + + if err != nil { + return trace, err + } + + t := http.DefaultTransport.(*http.Transport).Clone() + t.DisableKeepAlives = true + c := &http.Client{Transport: t} + + resp, err := c.Do(req) + if err != nil { + return trace, err + } + defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + trace.AllDone = time.Now() + + return trace, nil +}