HTTP API: register, list and remove webhooks, kept across restarts (closes #6)
check / check (push) Successful in 1m19s

An external app can register webhooks on a chat (`POST /api/v1/chats/{id}/webhooks` with a URL), list them, and remove one. Registering the same URL again returns the existing registration. Registrations are kept in `$DATA_DIR/webhooks.json`, rewritten whole on each change through a file created 0600, synced and renamed, so a crash leaves the old file or the new one; a file present but unreadable stops startup. Delivery of incoming messages is the next unit.

Disclosures: the JSON body reading is now one helper shared with the send endpoint; gosec G304 is suppressed on reading the webhooks file; the 0600-from-creation claim rests on `os.CreateTemp`'s source, not a system-call trace.

Model: opus-5-5
This commit was merged in pull request #17.
This commit is contained in:
2026-09-29 08:38:13 +02:00
parent f53b666119
commit 397fc95149
11 changed files with 1018 additions and 47 deletions
+47 -6
View File
@@ -1,5 +1,6 @@
// Package api is the bot's HTTP API, through which another program
// reads the bot's chats and sends messages in them.
// reads the bot's chats, sends messages in them and registers webhooks
// on them.
//
// Every request must carry the credential, as "Authorization: Bearer
// {credential}"; with no credential configured, every request is
@@ -14,6 +15,8 @@ import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
@@ -75,6 +78,10 @@ type Params struct {
// Token is the credential every request must carry. Empty refuses
// every request.
Token string
// Webhooks holds the webhooks registered on the chats; ReadWebhooks
// makes it.
Webhooks *Webhooks
}
// New returns the API's server. The caller starts it with
@@ -84,7 +91,13 @@ func New(p Params) *http.Server {
p.Log.Warn("API_TOKEN_FILE is not set, so the API refuses every request")
}
h := &handlers{log: p.Log, client: p.Client, userID: p.UserID, token: p.Token}
h := &handlers{
log: p.Log,
client: p.Client,
userID: p.UserID,
token: p.Token,
webhooks: p.Webhooks,
}
router := chi.NewRouter()
router.Use(securityHeaders, h.authenticate,
@@ -99,6 +112,9 @@ func New(p Params) *http.Server {
r.Get("/chats", h.handleChats())
r.Get("/chats/{id}/messages", h.handleMessages())
r.Post("/chats/{id}/messages", h.handleSend())
r.Get("/chats/{id}/webhooks", h.handleWebhooks())
r.Post("/chats/{id}/webhooks", h.handleRegister())
r.Delete("/chats/{id}/webhooks/{webhook_id}", h.handleRemove())
})
return &http.Server{
@@ -119,10 +135,11 @@ func New(p Params) *http.Server {
// handlers holds what the handlers share.
type handlers struct {
log *slog.Logger
client ChatClient
userID int64
token string
log *slog.Logger
client ChatClient
userID int64
token string
webhooks *Webhooks
}
// authenticate lets a request through only if it carries the
@@ -165,6 +182,30 @@ func (h *handlers) respondError(w http.ResponseWriter, status int, sentence stri
}{sentence})
}
// decodeBody decodes the request's JSON body into v. If the body is too
// large, or is not JSON of v's shape, it answers the request itself, 413
// or 400 naming example as the shape wanted, and returns false.
func (h *handlers) decodeBody(
w http.ResponseWriter, r *http.Request, v any, example string,
) bool {
body, err := io.ReadAll(r.Body)
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
h.respondError(w, http.StatusRequestEntityTooLarge, "the body is too large")
return false
}
if err != nil || json.Unmarshal(body, v) != nil {
h.respondError(w, http.StatusBadRequest, "the body must be JSON such as "+example)
return false
}
return true
}
// securityHeaders go on every response. The API returns JSON to
// programs, so a browser may not frame, sniff, cache or refer from it,
// nor give it the camera, microphone or location, and must reach it