Files
simplexcalc/internal/bot/bot.go
T
clawbot 7170576a8e
check / check (push) Successful in 1m45s
Webhooks: post each new incoming message (closes #7)
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
2026-09-29 07:19:56 +00:00

306 lines
8.7 KiB
Go

// Package bot is the calculator: it runs the SimpleX Chat client, makes
// sure the bot has a contact address that accepts everyone, and answers
// every text message with the value of the arithmetic in it.
package bot
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"sneak.berlin/go/simplexcalc/internal/api"
"sneak.berlin/go/simplexcalc/internal/calc"
"sneak.berlin/go/simplexcalc/internal/config"
"sneak.berlin/go/simplexcalc/internal/simplex"
)
// DisplayName is the name of the bot's SimpleX profile, given to it
// when the profile is created on the first start.
const DisplayName = "calc"
// Welcome is sent to everyone whose contact request the bot accepts.
const Welcome = "Send me arithmetic, such as 2 + 2, 5 * 5/2, 2^10 or 7 % 3, " +
"and I will reply with the result."
// ChatPort is where the chat client serves its API, on localhost inside
// the bot's own container.
const ChatPort = 5225
const (
// connectTimeout bounds the wait for a freshly started chat client
// to open its API, which includes creating or migrating the
// database.
connectTimeout = 60 * time.Second
// setupTimeout bounds each setup command. Creating an address
// talks to SimpleX relays over the network.
setupTimeout = 2 * time.Minute
// retryInterval paces the connection attempts.
retryInterval = 250 * time.Millisecond
// apiStopTimeout is how long API requests in progress get to finish
// when the bot stops, before their connections are closed.
apiStopTimeout = 5 * time.Second
dataDirMode = 0o700
)
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, 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 {
err := os.MkdirAll(cfg.DataDir, dataDirMode)
if err != nil {
return fmt.Errorf("creating data directory: %w", err)
}
// Before the chat client starts, so that a file that cannot be read
// stops the bot as configuration that cannot be read does.
webhooks, err := api.ReadWebhooks(cfg.DataDir)
if err != nil {
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,
// can finish its requests while the chat client still answers.
cliCtx, stopCLI := context.WithCancel(context.WithoutCancel(ctx))
cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(cfg.DataDir, "simplex"),
DisplayName, chatPort)
if err != nil {
stopCLI()
return err
}
defer func() {
stopCLI()
<-cli.Done()
}()
client, err := connect(ctx, log, cli, chatPort, handle(log, deliveries))
if err != nil {
return err
}
defer func() { _ = client.Close() }()
user, err := setUp(ctx, log, client)
if err != nil {
return err
}
srv := api.New(api.Params{
Log: log,
Client: client,
UserID: user.UserID,
Port: cfg.Port,
Token: cfg.APIToken,
Webhooks: webhooks,
})
served := make(chan error, 1)
go func() {
served <- srv.ListenAndServe()
}()
// Deferred last, so it runs first: requests in progress finish while
// the chat client is still there to answer them.
defer stopAPI(ctx, log, srv)
log.Info("starting the API", "port", cfg.Port)
select {
case <-ctx.Done():
return nil
case <-client.Done():
return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err())
case <-cli.Done():
return fmt.Errorf("%w: %w", errExited, cli.Err())
case err := <-served:
return fmt.Errorf("serving the API: %w", err)
}
}
// stopAPI stops the API server, giving requests in progress up to
// apiStopTimeout to finish before closing their connections.
func stopAPI(ctx context.Context, log *slog.Logger, srv *http.Server) {
// ctx is usually cancelled by now: that is why the bot is stopping.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apiStopTimeout)
defer cancel()
err := srv.Shutdown(ctx)
if err != nil {
log.Warn("stopping the API", "error", err)
_ = srv.Close()
}
}
// connect waits for the chat client to open its API on chatPort and
// 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()
url := "ws://127.0.0.1:" + strconv.Itoa(chatPort)
for {
client, err := simplex.Dial(ctx, url, log, onEvent)
if err == nil {
return client, nil
}
select {
case <-cli.Done():
return nil, fmt.Errorf("%w before opening its API: %w", errExited, cli.Err())
case <-ctx.Done():
return nil, fmt.Errorf("waiting for simplex-chat: %w (last error: %w)",
ctx.Err(), err)
case <-time.After(retryInterval):
}
}
}
// setUp gives the bot a long-term contact address, creating it on the
// first start, and sets it to accept every contact request and to greet
// each new contact. The settings are written on every start, so an
// address whose settings were changed by hand is put right. It returns
// the bot's user profile.
func setUp(
ctx context.Context, log *slog.Logger, client *simplex.Client,
) (simplex.User, error) {
ctx, cancel := context.WithTimeout(ctx, setupTimeout)
defer cancel()
user, err := client.ActiveUser(ctx)
if err != nil {
return user, fmt.Errorf("reading the bot's profile: %w", err)
}
link, ok, err := client.Address(ctx, user.UserID)
if err != nil {
return user, fmt.Errorf("reading the bot's address: %w", err)
}
if !ok {
log.Info("creating the bot's address")
link, err = client.CreateAddress(ctx, user.UserID)
if err != nil {
return user, fmt.Errorf("creating the bot's address: %w", err)
}
}
err = client.SetAddressSettings(ctx, user.UserID, simplex.AddressSettings{
AutoAccept: &simplex.AutoAccept{AcceptIncognito: false},
AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome},
})
if err != nil {
return user, fmt.Errorf("setting the bot's address to accept everyone: %w", err)
}
log.Info("ready",
"display_name", user.Profile.DisplayName,
"address", link.ShortLink,
"full_address", link.FullLink,
)
return user, nil
}
// 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:
var r simplex.NewChatItems
err := ev.Decode(&r)
if err != nil {
log.Warn("ignoring an event", "error", err)
return
}
for _, item := range r.ChatItems {
// Never waits, so no webhook holds up the replies.
deliveries.Add(item)
msg, ok := item.Message()
if !ok {
continue
}
err = c.SendText(msg.ContactID, msg.ItemID, Reply(msg.Text))
if err != nil {
log.Error("replying", "contact_id", msg.ContactID, "error", err)
}
}
case simplex.TypeContactConnected:
var r simplex.ContactConnected
err := ev.Decode(&r)
if err != nil {
log.Warn("ignoring an event", "error", err)
return
}
log.Info("contact connected", "contact_id", r.Contact.ContactID)
default:
log.Debug("event", "type", ev.Type)
}
}
}
// Reply is the bot's answer to a message: the value of the arithmetic
// in it, or a short explanation of why there is none.
func Reply(text string) string {
result, err := calc.Evaluate(text)
switch {
case err == nil:
return result
case errors.Is(err, calc.ErrTooLong):
return fmt.Sprintf("That is too long for me: at most %d characters, please.",
calc.MaxInputLength)
case errors.Is(err, calc.ErrDivisionByZero):
return "I cannot divide by zero."
case errors.Is(err, calc.ErrOutOfRange):
return "That needs a number too large or too small for me."
case errors.Is(err, calc.ErrNoRealResult):
return "A negative number to a fractional power has no real result."
default:
return "I only understand arithmetic: numbers, + - * /, ^ for a power, " +
"% for a remainder, and parentheses, such as 5 * 5/2 or 2^10."
}
}