Webhooks: post each new incoming message (closes #7)
check / check (push) Successful in 1m45s

Each message a contact sends in a direct chat is posted to each webhook
on that chat, as JSON holding `chat_id` and the message record of the
messages endpoint. The event handler only queues it: `api.Deliveries`
serves a queue of 100 with four goroutines, reads the chat's webhooks
there, and makes one POST per webhook, with a 10-second timeout, no
retry and no redirect followed. A full queue or a failed post is logged
by ids, never by URL or text. `bot.Run` stops it last, once the chat
client has exited. Tests cover the payload and its routing, a full
queue, failures, and a webhook that never answers; the README documents
the payload and what delivery promises.

Model: opus-5-5
This commit is contained in:
2026-09-29 07:19:56 +00:00
parent 397fc95149
commit 7170576a8e
8 changed files with 816 additions and 54 deletions
+20 -9
View File
@@ -56,10 +56,10 @@ var errExited = errors.New("simplex-chat exited")
// Run reads the webhooks kept in cfg.DataDir, starts the chat client with
// its database there, serving its API on localhost at chatPort, connects
// to it, sets up the bot's address, then answers messages and serves the
// bot's API until ctx is cancelled — which is a clean stop and returns
// nil — or until the chat client, the connection to it or the API's
// listener fails, which returns the error.
// to it, sets up the bot's address, then answers messages, posts them to
// the webhooks and serves the bot's API until ctx is cancelled — which is
// a clean stop and returns nil — or until the chat client, the connection
// to it or the API's listener fails, which returns the error.
func Run(
ctx context.Context, log *slog.Logger, cfg *config.Config, chatPort int,
) error {
@@ -75,6 +75,12 @@ func Run(
return err
}
// Posts the messages the event handler hands it to the webhooks. Its
// stop, deferred first, runs last, once the chat client has exited
// and can send no more messages; cancelling ctx does not reach it.
deliveries := api.StartDeliveries(context.WithoutCancel(ctx), log, webhooks)
defer deliveries.Stop()
// Cancelling this stops the chat client; the deferred wait makes
// Run return only once it has exited, whatever path Run takes.
// Cancelling ctx does not reach it, so that the API, stopped first,
@@ -94,7 +100,7 @@ func Run(
<-cli.Done()
}()
client, err := connect(ctx, log, cli, chatPort)
client, err := connect(ctx, log, cli, chatPort, handle(log, deliveries))
if err != nil {
return err
}
@@ -155,9 +161,10 @@ func stopAPI(ctx context.Context, log *slog.Logger, srv *http.Server) {
}
// connect waits for the chat client to open its API on chatPort and
// connects to it.
// connects to it, passing its events to onEvent.
func connect(
ctx context.Context, log *slog.Logger, cli *simplex.CLI, chatPort int,
onEvent simplex.EventHandler,
) (*simplex.Client, error) {
ctx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
@@ -165,7 +172,7 @@ func connect(
url := "ws://127.0.0.1:" + strconv.Itoa(chatPort)
for {
client, err := simplex.Dial(ctx, url, log, handle(log))
client, err := simplex.Dial(ctx, url, log, onEvent)
if err == nil {
return client, nil
}
@@ -228,8 +235,9 @@ func setUp(
return user, nil
}
// handle answers each text message a contact sends.
func handle(log *slog.Logger) simplex.EventHandler {
// handle answers each text message a contact sends, and hands every
// message a contact sends to deliveries, for the webhooks of its chat.
func handle(log *slog.Logger, deliveries *api.Deliveries) simplex.EventHandler {
return func(c *simplex.Client, ev simplex.Event) {
switch ev.Type {
case simplex.TypeNewChatItems:
@@ -243,6 +251,9 @@ func handle(log *slog.Logger) simplex.EventHandler {
}
for _, item := range r.ChatItems {
// Never waits, so no webhook holds up the replies.
deliveries.Add(item)
msg, ok := item.Message()
if !ok {
continue
+211 -40
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
@@ -35,11 +36,28 @@ const (
// long enough for the test to stop the bot meanwhile, and well
// within the 5 seconds the API gets to finish its requests.
contactsDelay = time.Second
// sent starts the name of the file the stand-in writes in the data
// directory for each message the bot sends, holding the command.
sent = "sent-"
)
// twoMessages is the event the stand-in sends once the bot is set up: a
// contact sending the bot two messages. It is reduced to the fields the
// bot reads.
const twoMessages = `{"type":"newChatItems","chatItems":[
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
"chatItem":{"chatDir":{"type":"directRcv"},
"meta":{"itemId":41,"itemTs":"2026-09-29T03:14:34Z"},
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}}}},
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
"chatItem":{"chatDir":{"type":"directRcv"},
"meta":{"itemId":42,"itemTs":"2026-09-29T03:14:35Z"},
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"3 * 3"}}}}]}`
// TestMain lets this test binary be the chat client as well: started
// under the chat client's name, as TestStopDuringRequest arranges, it is
// the stand-in instead of running the tests.
// under the chat client's name, as standInPath arranges, it is the
// stand-in instead of running the tests.
func TestMain(m *testing.M) {
if filepath.Base(os.Args[0]) == simplex.Binary {
standIn() // never returns
@@ -58,13 +76,13 @@ func standIn() {
return os.Args[slices.Index(os.Args, name)+1]
}
marker := filepath.Join(filepath.Dir(arg("--database")), asked)
dir := filepath.Dir(arg("--database"))
srv := &http.Server{
Addr: "127.0.0.1:" + arg("--chat-server-port"),
ReadHeaderTimeout: time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
answer(w, r, marker)
answer(w, r, dir)
}),
}
@@ -75,9 +93,11 @@ func standIn() {
}
// answer answers the commands on one connection with records reduced to
// the fields the bot reads. Asked for the contacts, it first creates
// the file marker, then holds its answer for contactsDelay.
func answer(w http.ResponseWriter, r *http.Request, marker string) {
// the fields the bot reads. Asked for the contacts, it first creates the
// file asked in dir, then holds its answer for contactsDelay. Once the
// bot is set up, it sends twoMessages. Each message the bot sends, it
// writes to a file in dir, named sent and the command's id.
func answer(w http.ResponseWriter, r *http.Request, dir string) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
@@ -101,13 +121,20 @@ func answer(w http.ResponseWriter, r *http.Request, marker string) {
name, _, _ := strings.Cut(cmd["cmd"], " ")
if name == "/_send" {
_ = os.WriteFile(filepath.Join(dir, sent+cmd["corrId"]),
[]byte(cmd["cmd"]), 0o600)
continue
}
record, ok := records[name]
if !ok {
continue
}
if name == "/_contacts" {
_ = os.WriteFile(marker, nil, 0o600)
_ = os.WriteFile(filepath.Join(dir, asked), nil, 0o600)
time.Sleep(contactsDelay)
}
@@ -116,6 +143,11 @@ func answer(w http.ResponseWriter, r *http.Request, marker string) {
"corrId": cmd["corrId"],
"resp": json.RawMessage(record),
})
// The last command of the bot's set-up.
if name == "/_address_settings" {
_ = conn.WriteJSON(map[string]any{"resp": json.RawMessage(twoMessages)})
}
}
}
@@ -123,8 +155,129 @@ func answer(w http.ResponseWriter, r *http.Request, marker string) {
// when the bot is told to stop still gets the chat client's answer,
// because the chat client is stopped only once the API has stopped.
func TestStopDuringRequest(t *testing.T) {
// Run starts the chat client from PATH: put this test binary there
// under the chat client's name.
t.Setenv("PATH", standInPath(t))
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
stop, done := runBot(t, cfg)
// Stop the bot once the request below is waiting on the chat client.
go func() {
for {
_, err := os.Stat(filepath.Join(cfg.DataDir, asked))
if err == nil {
stop()
}
select {
case <-done:
return
case <-time.After(10 * time.Millisecond):
}
}
}()
status, body := getChats(t, cfg.Port, done)
want := `{"chats":[{"id":3,"display_name":"tester","contact_deleted":false}]}` + "\n"
if status != http.StatusOK || body != want {
t.Errorf("GET /api/v1/chats = %d %q, want 200 %q", status, body, want)
}
}
// TestSlowWebhook: a webhook that takes the bot's POSTs and never answers
// them holds up neither the bot's replies nor its stop.
func TestSlowWebhook(t *testing.T) {
t.Setenv("PATH", standInPath(t))
posts := make(chan string, 2)
receiver := httptest.NewServer(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
posts <- string(body)
<-r.Context().Done() // until the bot abandons the POST
}))
t.Cleanup(receiver.Close)
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"),
[]byte(`{"webhooks":[{"id":"00112233445566778899aabbccddeeff",`+
`"chat_id":3,"url":"`+receiver.URL+`"}]}`), 0o600)
if err != nil {
t.Fatal(err)
}
stop, done := runBot(t, cfg)
// The webhook gets both messages, and holds on to them...
got := []string{receive(t, posts, done), receive(t, posts, done)}
slices.Sort(got)
want := []string{
`{"chat_id":3,"message":{"id":41,"direction":"received",` +
`"type":"text","text":"2 + 2","time":"2026-09-29T03:14:34Z"}}`,
`{"chat_id":3,"message":{"id":42,"direction":"received",` +
`"type":"text","text":"3 * 3","time":"2026-09-29T03:14:35Z"}}`,
}
if !slices.Equal(got, want) {
t.Errorf("posted %q, want %q", got, want)
}
// ...while the bot answers both.
replies := []string{
`/_send @3 json [{"quotedItemId":41,` +
`"msgContent":{"type":"text","text":"4"},"mentions":{}}]`,
`/_send @3 json [{"quotedItemId":42,` +
`"msgContent":{"type":"text","text":"9"},"mentions":{}}]`,
}
deadline := time.Now().Add(5 * time.Second)
for !slices.Equal(sentMessages(t, cfg.DataDir), replies) {
if time.Now().After(deadline) {
t.Fatalf("sent %q, want %q", sentMessages(t, cfg.DataDir), replies)
}
time.Sleep(10 * time.Millisecond)
}
// Stopping abandons the POSTs, rather than give the webhook the 10
// seconds it has to answer.
stop()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Run did not return while a webhook held a POST")
}
}
// TestUnreadableWebhooks: a webhooks file that cannot be read stops Run
// before it starts the chat client.
func TestUnreadableWebhooks(t *testing.T) {
// No chat client on PATH: starting one would fail with another error.
t.Setenv("PATH", t.TempDir())
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t)}
err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"), []byte("{"), 0o600)
if err != nil {
t.Fatal(err)
}
err = bot.Run(t.Context(), slog.New(slog.DiscardHandler), cfg, freePort(t))
if err == nil || !strings.Contains(err.Error(), "webhooks.json") {
t.Errorf("Run = %v, want an error naming webhooks.json", err)
}
}
// standInPath returns a directory holding this test binary under the
// chat client's name. Run starts the chat client from PATH, so with PATH
// set to it, Run starts the stand-in.
func standInPath(t *testing.T) string {
t.Helper()
bin := t.TempDir()
exe, err := os.Executable()
@@ -137,9 +290,15 @@ func TestStopDuringRequest(t *testing.T) {
t.Fatal(err)
}
t.Setenv("PATH", bin)
return bin
}
// runBot runs the bot with cfg until stop is called or the test ends,
// and fails the test if Run returns an error. done is closed once Run
// has returned.
func runBot(t *testing.T, cfg *config.Config) (context.CancelFunc, <-chan struct{}) {
t.Helper()
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
// Never bot.ChatPort: a real chat client may be listening there.
chatPort := freePort(t)
ctx, stop := context.WithCancel(t.Context())
@@ -162,43 +321,55 @@ func TestStopDuringRequest(t *testing.T) {
}
})
// Stop the bot once the request below is waiting on the chat client.
go func() {
for ctx.Err() == nil {
_, err := os.Stat(filepath.Join(cfg.DataDir, asked))
if err == nil {
stop()
}
time.Sleep(10 * time.Millisecond)
}
}()
status, body := getChats(t, cfg.Port, done)
want := `{"chats":[{"id":3,"display_name":"tester","contact_deleted":false}]}` + "\n"
if status != http.StatusOK || body != want {
t.Errorf("GET /api/v1/chats = %d %q, want 200 %q", status, body, want)
}
return stop, done
}
// TestUnreadableWebhooks: a webhooks file that cannot be read stops Run
// before it starts the chat client.
func TestUnreadableWebhooks(t *testing.T) {
// No chat client on PATH: starting one would fail with another error.
t.Setenv("PATH", t.TempDir())
// receive returns the next of posts. It fails the test if none comes
// within 10 seconds, or if Run returns first, which closes done.
func receive(t *testing.T, posts <-chan string, done <-chan struct{}) string {
t.Helper()
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t)}
select {
case p := <-posts:
return p
case <-done:
t.Fatal("Run returned before the webhook got the messages")
case <-time.After(10 * time.Second):
t.Fatal("the webhook did not get the messages")
}
err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"), []byte("{"), 0o600)
return ""
}
// sentMessages returns the commands of the messages the bot has sent, as
// the stand-in wrote them in dir, sorted.
func sentMessages(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
err = bot.Run(t.Context(), slog.New(slog.DiscardHandler), cfg, freePort(t))
if err == nil || !strings.Contains(err.Error(), "webhooks.json") {
t.Errorf("Run = %v, want an error naming webhooks.json", err)
var cmds []string
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), sent) {
continue
}
//nolint:gosec // G304: the test's own file.
cmd, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
t.Fatal(err)
}
cmds = append(cmds, string(cmd))
}
slices.Sort(cmds)
return cmds
}
// freePort returns a TCP port that nothing listens on at the moment.