A SimpleX Chat bot that answers arithmetic (closes #1)
check / check (push) Successful in 54s
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
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// 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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package bot_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/bot"
|
||||
"sneak.berlin/go/simplexcalc/internal/calc"
|
||||
)
|
||||
|
||||
// TestReply: a result is sent bare, and every way of failing gets its
|
||||
// own short explanation rather than silence.
|
||||
func TestReply(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for in, want := range map[string]string{
|
||||
"2 + 2": "4",
|
||||
"5 * 5/2": "12.5",
|
||||
} {
|
||||
if got := bot.Reply(in); got != want {
|
||||
t.Errorf("Reply(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
for in, want := range map[string]string{
|
||||
"hello": "I only understand arithmetic",
|
||||
"1 / 0": "I cannot divide by zero.",
|
||||
"1e400": "The result is too large for me.",
|
||||
strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me",
|
||||
} {
|
||||
if got := bot.Reply(in); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("Reply(%q) = %q, want it to start %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user