commit 5e84ebf0b7d2efb1d4435e415a950ad72436a85d
Author: Waldo <099waldo@gmail.com>
Date: Sat Jan 17 11:22:16 2026 -0700
initial commit
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
+}