Compare commits
2 Commits
0b2e83ba0c
...
bcf062ece0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcf062ece0 | ||
|
|
38635243f7 |
10
.env.example
Normal file
10
.env.example
Normal 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
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
/bin
|
||||
/result
|
||||
sox
|
||||
.env
|
||||
|
||||
27
CLAUDE.md
Normal file
27
CLAUDE.md
Normal 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
61
cmd_timer.go
Normal 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 <duration> (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)
|
||||
}
|
||||
@ -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
2
go.mod
@ -1,3 +1,5 @@
|
||||
module sox
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require github.com/joho/godotenv v1.5.1 // indirect
|
||||
|
||||
2
go.sum
Normal file
2
go.sum
Normal 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=
|
||||
4
main.go
4
main.go
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user