check / check (push) Successful in 54s
Remove the template's HTTP service, database and fx wiring. Add exact arithmetic on go/parser and go/constant, a client that runs simplex-chat as a child process and drives its WebSocket API, and the bot, which keeps an auto-accepting address and replies to each message. The image adds the checksum-pinned simplex-chat v7.0.2 on Ubuntu 22.04. Model: opus-5-5
231 lines
6.0 KiB
Go
231 lines
6.0 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"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"sneak.berlin/go/simplexcalc/internal/calc"
|
|
"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
|
|
|
|
dataDirMode = 0o700
|
|
)
|
|
|
|
var errExited = errors.New("simplex-chat exited")
|
|
|
|
// Run starts the chat client with its database in dataDir, connects to
|
|
// it, sets up the bot's address, and answers messages until ctx is
|
|
// cancelled — which is a clean stop and returns nil — or until the chat
|
|
// client or the connection to it fails, which returns the error.
|
|
func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
|
err := os.MkdirAll(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(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() }()
|
|
|
|
err = setUp(ctx, log, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error {
|
|
ctx, cancel := context.WithTimeout(ctx, setupTimeout)
|
|
defer cancel()
|
|
|
|
user, err := client.ActiveUser(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("reading the bot's profile: %w", err)
|
|
}
|
|
|
|
link, ok, err := client.Address(ctx, user.UserID)
|
|
if err != nil {
|
|
return 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 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 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 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."
|
|
}
|
|
}
|