check / check (push) Successful in 2m3s
Each message a contact sends in a direct chat is posted to each webhook registered on that chat: `POST`, `Content-Type: application/json`, body `{"chat_id":N,"message":{...}}` with the same record the messages endpoint returns. The event handler only queues the message; four goroutines post it, one attempt each with a 10-second timeout and no retries, and with 100 already waiting a message is dropped and logged, so a slow webhook never delays the bot's replies. Posting stops last, after the chat client.
Disclosures: redirects are not followed; a logged failure can name the webhook's host but never its path, query or credentials; posts still in flight at shutdown are abandoned and logged.
Model: opus-5-5
306 lines
8.7 KiB
Go
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."
|
|
}
|
|
}
|