check / check (push) Successful in 1m1s
The bot now serves an HTTP API on PORT (default 8080) beside the chat client. Every request needs the credential read at startup from the file named by API_TOKEN_FILE, sent as a bearer token; without one, every request is refused. Responses carry the security headers, bodies are capped at 64 KiB and each request's work at 10 seconds. GET /api/v1/chats lists the bot's contacts from the chat client's /_contacts command, ordered by id, and marks the contacts who deleted their chat with the bot, which the chat client keeps listing. bot.Run starts the API after set-up and stops it within 5 seconds; a listener failure ends the bot as a chat client failure does. Model: opus-5-5
279 lines
7.4 KiB
Go
279 lines
7.4 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 or 5 * 5/2, " +
|
|
"and I will reply with the result."
|
|
|
|
const (
|
|
// chatPort is where the chat client serves its API, on localhost
|
|
// inside the bot's own container.
|
|
chatPort = 5225
|
|
|
|
// 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 starts the chat client with its database in cfg.DataDir, connects
|
|
// to it, sets up the bot's address, then answers messages and serves the
|
|
// 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) error {
|
|
err := os.MkdirAll(cfg.DataDir, dataDirMode)
|
|
if err != nil {
|
|
return fmt.Errorf("creating data directory: %w", err)
|
|
}
|
|
|
|
// Cancelling this stops the chat client; the deferred wait makes
|
|
// Run return only once it has exited, whatever path Run takes.
|
|
cliCtx, stopCLI := context.WithCancel(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)
|
|
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,
|
|
})
|
|
|
|
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 and connects to it.
|
|
func connect(
|
|
ctx context.Context, log *slog.Logger, cli *simplex.CLI,
|
|
) (*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, handle(log))
|
|
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.
|
|
func handle(log *slog.Logger) 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 {
|
|
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.ErrTooLarge):
|
|
return "The result is too large for me."
|
|
default:
|
|
return "I only understand arithmetic: numbers, + - * / and " +
|
|
"parentheses, such as 5 * 5/2."
|
|
}
|
|
}
|