Compare commits

...

2 Commits

Author SHA1 Message Date
Waldo
bcf062ece0 Added .env to gitignore, added CLAUDE.md 2026-03-07 07:25:36 -07:00
Waldo
38635243f7 Add timer command with .env support
Adds a `timer` command that immediately confirms the timer and posts a
notification back to Campfire when it fires, using CAMPFIRE_URL and
CAMPFIRE_TOKEN. Adds godotenv so the bot loads a .env file automatically
on startup. Includes a .env.example documenting all env vars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 07:10:36 -07:00
8 changed files with 108 additions and 1 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Sox environment variables
# Port to listen on (default: 4567)
PORT=4567
# Base URL of your Campfire instance (used by the timer command to post notifications)
CAMPFIRE_URL=https://your-campfire-instance.example.com
# Token used in the notification POST URL path
CAMPFIRE_TOKEN=your_token_here

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/bin
/result
sox
.env

27
CLAUDE.md Normal file
View File

@ -0,0 +1,27 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Sox is a Campfire bot written in Go. It runs as an HTTP server (default port `4567`, overridable via `PORT` env var) that receives webhook POST requests from Campfire and responds with HTML-formatted text.
## Commands
```bash
make build # builds binary to bin/sox
make test # runs all tests with verbose output
go test -run TestSplitFirstWord ./... # run a single test
```
The `math` command requires `qalc` (libqalculate) on PATH. Tests that need it skip automatically if it's absent.
## Architecture
**Plugin system** (`plugin.go`): Each command is a struct implementing the `Plugin` interface (`Name()`, `ShortHelp()`, `DetailedHelp()`, `Execute()`). Plugins self-register via `registerPlugin()` called from `init()` in `main.go`. The global `registry` map (`map[string]Plugin`) routes commands by name.
**Request handling** (`main.go`): `commandHandler` decodes the Campfire JSON webhook into `BotMessage`, then `executeCommand` splits the plain-text body into command + arguments. If the command isn't recognized, it falls back to the user's last successfully used command (stored in `userLastCommandType`, a `map[string]string` keyed by username). The `help` command is excluded from last-command memory.
**Adding a new command**: Create a `cmd_<name>.go` file, implement the `Plugin` interface, and add `registerPlugin(YourPlugin{})` to `init()` in `main.go`.
**HTTP tracing** (`request_trace.go`, `cmd_trace.go`): `TraceRequest` uses `net/http/httptrace` to instrument a GET request with a 3-second timeout and returns a `RequestTrace` struct with timestamps for each phase (DNS, connect, TLS, headers, first byte, last byte).

61
cmd_timer.go Normal file
View File

@ -0,0 +1,61 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
type TimerPlugin struct{}
func (p TimerPlugin) Name() string { return "timer" }
func (p TimerPlugin) ShortHelp() string { return "set a timer" }
func (p TimerPlugin) DetailedHelp() string {
return "Usage: timer <duration>\nSets a timer. When it goes off, posts a message to the room.\nExamples: timer 5m, timer 1h30m, timer 90s"
}
func (p TimerPlugin) Execute(msg BotMessage, args string) string {
d, err := time.ParseDuration(strings.TrimSpace(args))
if err != nil || d <= 0 {
return "Usage: timer &lt;duration&gt; (e.g. timer 5m, timer 1h30m)"
}
if os.Getenv("CAMPFIRE_URL") == "" || os.Getenv("CAMPFIRE_TOKEN") == "" {
return "Cannot set timer: CAMPFIRE_URL and CAMPFIRE_TOKEN must be configured."
}
firesAt := time.Now().Add(d)
go func() {
time.Sleep(d)
sendTimerNotification(msg, d)
}()
return fmt.Sprintf("Timer set for %s. Will go off at %s.", d, firesAt.Format("15:04:05"))
}
func sendTimerNotification(msg BotMessage, d time.Duration) {
baseURL := os.Getenv("CAMPFIRE_URL")
token := os.Getenv("CAMPFIRE_TOKEN")
if baseURL == "" || token == "" {
log.Println("timer: CAMPFIRE_URL or CAMPFIRE_TOKEN not set, cannot send notification")
return
}
url := fmt.Sprintf("%s/rooms/%d/%s/messages", baseURL, msg.Room.ID, token)
body := fmt.Sprintf("%s your %s timer is done!", msg.User.Name, d)
resp, err := http.Post(url, "text/plain", strings.NewReader(body))
if err != nil {
log.Printf("timer: failed to send notification: %v", err)
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
}

View File

@ -61,7 +61,7 @@
in
{
default = pkgs.mkShell {
buildInputs = with pkgs; [ go gopls gotools go-tools ];
buildInputs = with pkgs; [ go gopls gotools go-tools libqalculate ];
};
});

2
go.mod
View File

@ -1,3 +1,5 @@
module sox
go 1.22.0
require github.com/joho/godotenv v1.5.1 // indirect

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

View File

@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"os"
"github.com/joho/godotenv"
)
type BotMessage struct {
@ -35,9 +37,11 @@ func init() {
registerPlugin(HiPlugin{})
registerPlugin(TracePlugin{})
registerPlugin(HelpPlugin{})
registerPlugin(TimerPlugin{})
}
func main() {
godotenv.Load()
fmt.Println("Sox started")
http.HandleFunc("/", commandHandler)