// 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." } }