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,149 @@
|
||||
package simplex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Binary is the chat client's executable, looked up on PATH.
|
||||
const Binary = "simplex-chat"
|
||||
|
||||
// stopGrace is how long the chat client gets to exit after SIGTERM
|
||||
// before it is killed.
|
||||
const stopGrace = 10 * time.Second
|
||||
|
||||
// maxLine bounds how much of one unterminated output line is held
|
||||
// before it is logged anyway.
|
||||
const maxLine = 64 << 10
|
||||
|
||||
// CLI is a running chat client process.
|
||||
type CLI struct {
|
||||
done chan struct{}
|
||||
err error // how the process ended; valid once done is closed
|
||||
}
|
||||
|
||||
// StartCLI launches the chat client with its database at dbPrefix,
|
||||
// serving its API on localhost at port. On the first start, with no
|
||||
// database yet, the client creates a bot profile named displayName;
|
||||
// every later start uses the profile already in the database.
|
||||
//
|
||||
// Cancelling ctx stops the client: SIGTERM, then SIGKILL if it has not
|
||||
// exited after stopGrace. Its output is logged line by line, so the
|
||||
// process emits one log format.
|
||||
func StartCLI(
|
||||
ctx context.Context, log *slog.Logger, dbPrefix, displayName string, port int,
|
||||
) (*CLI, error) {
|
||||
// No shell is involved: each argument reaches the client as one
|
||||
// argv entry, whatever it contains.
|
||||
//nolint:gosec // G204: the arguments are this program's own settings.
|
||||
cmd := exec.CommandContext(ctx, Binary,
|
||||
"--database", dbPrefix,
|
||||
"--chat-server-port", strconv.Itoa(port),
|
||||
"--create-bot-display-name", displayName,
|
||||
// Confirms the database migrations a newer client brings,
|
||||
// which it would otherwise wait to have confirmed on a
|
||||
// terminal that nobody is at.
|
||||
"--yes-migrate",
|
||||
)
|
||||
|
||||
cmd.Cancel = func() error {
|
||||
return cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
cmd.WaitDelay = stopGrace
|
||||
|
||||
out := &lineLogger{log: log}
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("starting %s: %w", Binary, err)
|
||||
}
|
||||
|
||||
cli := &CLI{done: make(chan struct{})}
|
||||
|
||||
go func() {
|
||||
defer close(cli.done)
|
||||
|
||||
cli.err = cmd.Wait()
|
||||
|
||||
out.flush()
|
||||
}()
|
||||
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
// Done is closed when the process has exited; Err then says how.
|
||||
func (c *CLI) Done() <-chan struct{} {
|
||||
return c.done
|
||||
}
|
||||
|
||||
// Err returns how the process ended. Call it only after Done is closed.
|
||||
func (c *CLI) Err() error {
|
||||
return c.err
|
||||
}
|
||||
|
||||
// lineLogger is the chat client's stdout and stderr.
|
||||
type lineLogger struct {
|
||||
log *slog.Logger
|
||||
|
||||
// mu covers the final flush after Wait, which can overlap a last
|
||||
// Write when Wait gave up on the output after stopGrace.
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (l *lineLogger) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.buf = append(l.buf, p...)
|
||||
|
||||
for {
|
||||
line, rest, found := bytes.Cut(l.buf, []byte{'\n'})
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
|
||||
l.emit(line)
|
||||
l.buf = rest
|
||||
}
|
||||
|
||||
if len(l.buf) > maxLine {
|
||||
l.flushLocked()
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// flush logs what is left of an unterminated last line.
|
||||
func (l *lineLogger) flush() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.flushLocked()
|
||||
}
|
||||
|
||||
func (l *lineLogger) flushLocked() {
|
||||
if len(l.buf) > 0 {
|
||||
l.emit(l.buf)
|
||||
}
|
||||
|
||||
l.buf = nil
|
||||
}
|
||||
|
||||
func (l *lineLogger) emit(line []byte) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
l.log.Info("simplex-chat", "output", string(line))
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// Package simplex runs the SimpleX Chat command-line client and talks
|
||||
// to it over its WebSocket API.
|
||||
//
|
||||
// The protocol, documented in the simplex-chat repository under bots/:
|
||||
// a command goes out as {"corrId": "...", "cmd": "..."}, and the client
|
||||
// answers it with {"corrId": "...", "resp": {...}} carrying the same id.
|
||||
// Everything it sends without a corrId is an event. The API has no
|
||||
// authentication; the client binds it to localhost only.
|
||||
package simplex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// maxMessageSize bounds one message from the chat client. The largest
|
||||
// thing it sends is a record carrying a contact's profile picture, well
|
||||
// under this.
|
||||
const maxMessageSize = 16 << 20
|
||||
|
||||
var (
|
||||
// ErrClosed is returned by Command once the connection has ended.
|
||||
ErrClosed = errors.New("connection to the chat client closed")
|
||||
|
||||
errUnexpected = errors.New("unexpected response")
|
||||
errCommand = errors.New("command failed")
|
||||
)
|
||||
|
||||
// EventHandler receives each event the chat client sends. Events are
|
||||
// delivered one at a time, on the goroutine that also delivers command
|
||||
// responses: a handler may Send, but must never wait on Command, whose
|
||||
// response could then never arrive.
|
||||
type EventHandler func(c *Client, ev Event)
|
||||
|
||||
// Client is a connection to the chat client's WebSocket API.
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
log *slog.Logger
|
||||
onEvent EventHandler
|
||||
|
||||
// writeMu serialises writes: the connection allows one writer at
|
||||
// a time, and Command and Send are called from different
|
||||
// goroutines.
|
||||
writeMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
lastID uint64
|
||||
waiting map[string]chan Event
|
||||
|
||||
done chan struct{}
|
||||
err error // why the read loop ended; valid once done is closed
|
||||
}
|
||||
|
||||
// Dial connects to the chat client's API at url and starts reading from
|
||||
// it, passing every event to onEvent.
|
||||
func Dial(
|
||||
ctx context.Context, url string, log *slog.Logger, onEvent EventHandler,
|
||||
) (*Client, error) {
|
||||
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, url, nil)
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connecting to %s: %w", url, err)
|
||||
}
|
||||
|
||||
conn.SetReadLimit(maxMessageSize)
|
||||
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
log: log,
|
||||
onEvent: onEvent,
|
||||
waiting: make(map[string]chan Event),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go c.read()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Done is closed when the connection ends; Err then says why.
|
||||
func (c *Client) Done() <-chan struct{} {
|
||||
return c.done
|
||||
}
|
||||
|
||||
// Err returns why the connection ended. Call it only after Done is
|
||||
// closed.
|
||||
func (c *Client) Err() error {
|
||||
return c.err
|
||||
}
|
||||
|
||||
// Close ends the connection.
|
||||
func (c *Client) Close() error {
|
||||
err := c.conn.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("closing connection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActiveUser returns the chat client's active user profile.
|
||||
func (c *Client) ActiveUser(ctx context.Context) (User, error) {
|
||||
var r struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
err := c.command(ctx, cmdShowActiveUser, TypeActiveUser, &r)
|
||||
|
||||
return r.User, err
|
||||
}
|
||||
|
||||
// Address returns the user's long-term contact address, and false if
|
||||
// the user has none.
|
||||
func (c *Client) Address(ctx context.Context, userID int64) (ConnLink, bool, error) {
|
||||
//nolint:tagliatelle // the chat client's wire format.
|
||||
var r struct {
|
||||
ContactLink struct {
|
||||
ConnLinkContact ConnLink `json:"connLinkContact"`
|
||||
} `json:"contactLink"`
|
||||
}
|
||||
|
||||
err := c.command(ctx, cmdShowAddress(userID), TypeUserContactLink, &r)
|
||||
|
||||
var cerr *CommandError
|
||||
if errors.As(err, &cerr) && cerr.Detail == "userContactLinkNotFound" {
|
||||
return ConnLink{}, false, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ConnLink{}, false, err
|
||||
}
|
||||
|
||||
return r.ContactLink.ConnLinkContact, true, nil
|
||||
}
|
||||
|
||||
// CreateAddress creates the user's long-term contact address.
|
||||
func (c *Client) CreateAddress(ctx context.Context, userID int64) (ConnLink, error) {
|
||||
//nolint:tagliatelle // the chat client's wire format.
|
||||
var r struct {
|
||||
ConnLinkContact ConnLink `json:"connLinkContact"`
|
||||
}
|
||||
|
||||
err := c.command(ctx, cmdCreateAddress(userID), TypeUserContactLinkCreated, &r)
|
||||
|
||||
return r.ConnLinkContact, err
|
||||
}
|
||||
|
||||
// SetAddressSettings replaces the settings of the user's address.
|
||||
func (c *Client) SetAddressSettings(
|
||||
ctx context.Context, userID int64, s AddressSettings,
|
||||
) error {
|
||||
cmd, err := cmdSetAddressSettings(userID, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil)
|
||||
}
|
||||
|
||||
// SendText sends a text message to a contact, as a reply to the message
|
||||
// quotedItemID (0 for none). It does not wait for the chat client to
|
||||
// accept it; a failure is logged when the client's answer arrives.
|
||||
func (c *Client) SendText(contactID, quotedItemID int64, text string) error {
|
||||
cmd, err := cmdSendText(contactID, quotedItemID, text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
id := c.nextID()
|
||||
c.mu.Unlock()
|
||||
|
||||
return c.write(id, cmd)
|
||||
}
|
||||
|
||||
// CommandError is a command the chat client refused. Type and Detail
|
||||
// are the discriminators of its chatError record, such as "errorStore"
|
||||
// and "userContactLinkNotFound".
|
||||
type CommandError struct {
|
||||
Type string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *CommandError) Error() string {
|
||||
return fmt.Sprintf("%s: %s/%s", errCommand, e.Type, e.Detail)
|
||||
}
|
||||
|
||||
func (e *CommandError) Unwrap() error {
|
||||
return errCommand
|
||||
}
|
||||
|
||||
// command sends cmd, waits for its response, and, if the response has
|
||||
// type want, decodes it into out (unless out is nil).
|
||||
func (c *Client) command(ctx context.Context, cmd, want string, out any) error {
|
||||
ch := make(chan Event, 1)
|
||||
|
||||
c.mu.Lock()
|
||||
id := c.nextID()
|
||||
c.waiting[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.waiting, id)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
err := c.write(id, cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ev Event
|
||||
|
||||
select {
|
||||
case ev = <-ch:
|
||||
case <-c.done:
|
||||
return fmt.Errorf("%w: %w", ErrClosed, c.err)
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("waiting for a response to %q: %w", cmdName(cmd), ctx.Err())
|
||||
}
|
||||
|
||||
switch ev.Type {
|
||||
case want:
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ev.Decode(out)
|
||||
case TypeChatCmdError:
|
||||
return commandError(ev)
|
||||
default:
|
||||
return fmt.Errorf("%w to %q: %s", errUnexpected, cmdName(cmd), ev.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// nextID returns a fresh correlation id. The caller holds c.mu.
|
||||
func (c *Client) nextID() string {
|
||||
c.lastID++
|
||||
|
||||
return strconv.FormatUint(c.lastID, 10)
|
||||
}
|
||||
|
||||
func (c *Client) write(id, cmd string) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
err := c.conn.WriteJSON(command{CorrID: id, Cmd: cmd})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending %q: %w", cmdName(cmd), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// read is the only reader of the connection. It runs until the
|
||||
// connection fails or is closed.
|
||||
func (c *Client) read() {
|
||||
defer close(c.done)
|
||||
|
||||
for {
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
c.err = err
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.dispatch(data)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch routes one message: a response to whoever waits for it, an
|
||||
// event to the handler. A message that does not parse is logged and
|
||||
// skipped rather than ending the connection; the API documentation
|
||||
// warns that records change between releases.
|
||||
func (c *Client) dispatch(data []byte) {
|
||||
var env envelope
|
||||
|
||||
err := json.Unmarshal(data, &env)
|
||||
if err != nil {
|
||||
c.log.Warn("undecodable message from the chat client", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var head tagged
|
||||
|
||||
err = json.Unmarshal(env.Resp, &head)
|
||||
if err != nil {
|
||||
c.log.Warn("undecodable record from the chat client", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ev := Event{Type: head.Type, raw: env.Resp}
|
||||
|
||||
if env.CorrID == "" {
|
||||
c.onEvent(c, ev)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
ch, ok := c.waiting[env.CorrID]
|
||||
c.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
ch <- ev
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The response to a SendText: nobody waits for it, so a failure
|
||||
// is reported here or nowhere.
|
||||
if ev.Type == TypeChatCmdError {
|
||||
c.log.Warn("sending a message failed", "error", commandError(ev))
|
||||
}
|
||||
}
|
||||
|
||||
func commandError(ev Event) error {
|
||||
var r cmdError
|
||||
|
||||
err := ev.Decode(&r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e := &CommandError{Type: r.ChatError.Type}
|
||||
|
||||
for _, detail := range []*tagged{
|
||||
r.ChatError.ErrorType, r.ChatError.StoreError, r.ChatError.AgentError,
|
||||
} {
|
||||
if detail != nil {
|
||||
e.Detail = detail.Type
|
||||
}
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// cmdName is a command without its arguments, for error messages: the
|
||||
// arguments of /_send are a message someone wrote.
|
||||
func cmdName(cmd string) string {
|
||||
name, _, _ := strings.Cut(cmd, " ")
|
||||
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package simplex_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||
)
|
||||
|
||||
const testTimeout = 5 * time.Second
|
||||
|
||||
// cmdUser is the command that reads the active user profile.
|
||||
const cmdUser = "/user"
|
||||
|
||||
// Records as the chat client sends them, with fields this package does
|
||||
// not read left in, since ignoring those is part of the contract.
|
||||
const (
|
||||
activeUser = `{"type":"activeUser","user":{"userId":1,"agentUserId":1,
|
||||
"profile":{"profileId":1,"displayName":"calc","fullName":"",
|
||||
"peerType":"bot","localAlias":""},"activeUser":true}}`
|
||||
|
||||
addressNotFound = `{"type":"chatCmdError","chatError":{"type":"errorStore",
|
||||
"storeError":{"type":"userContactLinkNotFound"}}}`
|
||||
|
||||
addressCreated = `{"type":"userContactLinkCreated","user":{"userId":1},
|
||||
"connLinkContact":{"connFullLink":"simplex:/contact#/?v=2-7&smp=x",
|
||||
"connShortLink":"https://smp.example/a#key"}}`
|
||||
|
||||
addressUpdated = `{"type":"userContactLinkUpdated","user":{"userId":1},
|
||||
"contactLink":{"userContactLinkId":1}}`
|
||||
|
||||
noActiveUser = `{"type":"chatCmdError","chatError":{"type":"error",
|
||||
"errorType":{"type":"noActiveUser"}}}`
|
||||
|
||||
contactConnected = `{"type":"contactConnected","user":{"userId":1},
|
||||
"contact":{"contactId":3,"localDisplayName":"alice"}}`
|
||||
)
|
||||
|
||||
// fakeChat stands in for the chat client's API. It answers each command
|
||||
// with the record in replies under the command's first word, stays
|
||||
// silent for a command it has no record for, and reports every command
|
||||
// it receives on got.
|
||||
type fakeChat struct {
|
||||
replies map[string]string
|
||||
got chan string
|
||||
|
||||
mu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
up chan struct{}
|
||||
}
|
||||
|
||||
func newFakeChat(t *testing.T, replies map[string]string) (*fakeChat, string) {
|
||||
t.Helper()
|
||||
|
||||
f := &fakeChat{
|
||||
replies: replies,
|
||||
got: make(chan string, 16),
|
||||
up: make(chan struct{}),
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(f)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return f, "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
}
|
||||
|
||||
func (f *fakeChat) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.conn = conn
|
||||
f.mu.Unlock()
|
||||
close(f.up)
|
||||
|
||||
for {
|
||||
//nolint:tagliatelle // the chat client's wire format.
|
||||
var cmd struct {
|
||||
CorrID string `json:"corrId"`
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
|
||||
err = conn.ReadJSON(&cmd)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
f.got <- cmd.Cmd
|
||||
|
||||
name, _, _ := strings.Cut(cmd.Cmd, " ")
|
||||
if resp, ok := f.replies[name]; ok {
|
||||
f.send(cmd.CorrID, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send writes one record, as a response to corrID, or as an event when
|
||||
// corrID is empty.
|
||||
func (f *fakeChat) send(corrID, resp string) {
|
||||
msg := map[string]any{"resp": json.RawMessage(resp)}
|
||||
if corrID != "" {
|
||||
msg["corrId"] = corrID
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
_ = f.conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
func (f *fakeChat) hangUp() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
_ = f.conn.Close()
|
||||
}
|
||||
|
||||
func (f *fakeChat) next(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case cmd := <-f.got:
|
||||
return cmd
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("the client sent no command")
|
||||
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func dial(
|
||||
t *testing.T, url string, onEvent simplex.EventHandler,
|
||||
) (*simplex.Client, context.Context) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
if onEvent == nil {
|
||||
onEvent = func(*simplex.Client, simplex.Event) {}
|
||||
}
|
||||
|
||||
c, err := simplex.Dial(ctx, url, slog.New(slog.DiscardHandler), onEvent)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
return c, ctx
|
||||
}
|
||||
|
||||
// TestAddressSetup walks the calls the bot makes on its first start,
|
||||
// and checks the exact commands that reach the chat client.
|
||||
func TestAddressSetup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, url := newFakeChat(t, map[string]string{
|
||||
cmdUser: activeUser,
|
||||
"/_show_address": addressNotFound,
|
||||
"/_address": addressCreated,
|
||||
"/_address_settings": addressUpdated,
|
||||
})
|
||||
c, ctx := dial(t, url, nil)
|
||||
|
||||
user, err := c.ActiveUser(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ActiveUser: %v", err)
|
||||
}
|
||||
|
||||
if user.UserID != 1 || user.Profile.DisplayName != "calc" {
|
||||
t.Errorf("ActiveUser = %+v, want user 1 named calc", user)
|
||||
}
|
||||
|
||||
_, ok, err := c.Address(ctx, 1)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("Address = %v, %v; want no address and no error", ok, err)
|
||||
}
|
||||
|
||||
link, err := c.CreateAddress(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAddress: %v", err)
|
||||
}
|
||||
|
||||
if link.ShortLink != "https://smp.example/a#key" ||
|
||||
link.FullLink != "simplex:/contact#/?v=2-7&smp=x" {
|
||||
t.Errorf("CreateAddress = %+v", link)
|
||||
}
|
||||
|
||||
err = c.SetAddressSettings(ctx, 1, simplex.AddressSettings{
|
||||
AutoAccept: &simplex.AutoAccept{},
|
||||
AutoReply: &simplex.MsgContent{Type: "text", Text: "hi"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetAddressSettings: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
cmdUser,
|
||||
"/_show_address 1",
|
||||
"/_address 1",
|
||||
`/_address_settings 1 {"businessAddress":false,` +
|
||||
`"autoAccept":{"acceptIncognito":false},` +
|
||||
`"autoReply":{"type":"text","text":"hi"}}`,
|
||||
} {
|
||||
if got := f.next(t); got != want {
|
||||
t.Errorf("command = %s\nwant %s", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusedCommand: a command the chat client refuses is an error
|
||||
// that names the reason.
|
||||
func TestRefusedCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, url := newFakeChat(t, map[string]string{cmdUser: noActiveUser})
|
||||
c, ctx := dial(t, url, nil)
|
||||
|
||||
_, err := c.ActiveUser(ctx)
|
||||
|
||||
var cerr *simplex.CommandError
|
||||
if !errors.As(err, &cerr) || cerr.Detail != "noActiveUser" {
|
||||
t.Errorf("ActiveUser error = %v, want a CommandError for noActiveUser", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnexpectedResponse: a response of the wrong type is an error, not
|
||||
// a zero value.
|
||||
func TestUnexpectedResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, url := newFakeChat(t, map[string]string{cmdUser: addressUpdated})
|
||||
c, ctx := dial(t, url, nil)
|
||||
|
||||
_, err := c.ActiveUser(ctx)
|
||||
if err == nil {
|
||||
t.Error("ActiveUser accepted a userContactLinkUpdated response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventsAndReplies: an event reaches the handler, and a reply sent
|
||||
// from inside the handler reaches the chat client.
|
||||
func TestEventsAndReplies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, url := newFakeChat(t, nil)
|
||||
|
||||
seen := make(chan simplex.Event, 1)
|
||||
|
||||
dial(t, url, func(c *simplex.Client, ev simplex.Event) {
|
||||
seen <- ev
|
||||
|
||||
err := c.SendText(3, 7, `4 "exactly"`)
|
||||
if err != nil {
|
||||
t.Errorf("SendText: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
<-f.up
|
||||
f.send("", contactConnected)
|
||||
|
||||
select {
|
||||
case ev := <-seen:
|
||||
var r simplex.ContactConnected
|
||||
|
||||
err := ev.Decode(&r)
|
||||
if err != nil || ev.Type != simplex.TypeContactConnected ||
|
||||
r.Contact.ContactID != 3 {
|
||||
t.Errorf("event = %s %+v (%v), want contactConnected for contact 3",
|
||||
ev.Type, r, err)
|
||||
}
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("the event never reached the handler")
|
||||
}
|
||||
|
||||
want := `/_send @3 json [{"quotedItemId":7,` +
|
||||
`"msgContent":{"type":"text","text":"4 \"exactly\""},"mentions":{}}]`
|
||||
if got := f.next(t); got != want {
|
||||
t.Errorf("command = %s\nwant %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectionLoss: when the chat client goes away, Done closes and a
|
||||
// command fails instead of waiting for an answer that cannot come.
|
||||
func TestConnectionLoss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, url := newFakeChat(t, nil)
|
||||
c, ctx := dial(t, url, nil)
|
||||
|
||||
<-f.up
|
||||
f.hangUp()
|
||||
|
||||
select {
|
||||
case <-c.Done():
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("Done did not close after the connection ended")
|
||||
}
|
||||
|
||||
if c.Err() == nil {
|
||||
t.Error("Err is nil after the connection ended")
|
||||
}
|
||||
|
||||
_, err := c.ActiveUser(ctx)
|
||||
if err == nil {
|
||||
t.Error("a command on a closed connection succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package simplex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Response and event types this package and the bot act on. The chat
|
||||
// client sends many more; every other type is ignored, as its API
|
||||
// documentation requires of clients.
|
||||
const (
|
||||
TypeActiveUser = "activeUser"
|
||||
TypeUserContactLink = "userContactLink"
|
||||
TypeUserContactLinkCreated = "userContactLinkCreated"
|
||||
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
||||
TypeNewChatItems = "newChatItems"
|
||||
TypeContactConnected = "contactConnected"
|
||||
TypeChatCmdError = "chatCmdError"
|
||||
)
|
||||
|
||||
// Event is one message from the chat client: a response to a command,
|
||||
// or an event it sends unprompted. The protocol is a discriminated
|
||||
// union on "type"; the rest of the record is decoded on demand, into a
|
||||
// struct declaring only the fields the caller reads, so a record whose
|
||||
// other fields changed shape between releases still decodes.
|
||||
type Event struct {
|
||||
Type string
|
||||
raw json.RawMessage
|
||||
}
|
||||
|
||||
// Decode unmarshals the whole record into v.
|
||||
func (e Event) Decode(v any) error {
|
||||
err := json.Unmarshal(e.raw, v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding %s: %w", e.Type, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wire types, reduced to the fields this program uses. The field names
|
||||
// are the chat client's, hence camelCase in the tags.
|
||||
//
|
||||
//nolint:tagliatelle // the chat client's wire format, not ours to name.
|
||||
type (
|
||||
// User is the chat client's local user profile: the bot itself.
|
||||
User struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Profile struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"profile"`
|
||||
}
|
||||
|
||||
// ConnLink is a SimpleX link. The short form is what people share;
|
||||
// the full form is what older clients understand.
|
||||
ConnLink struct {
|
||||
FullLink string `json:"connFullLink"`
|
||||
ShortLink string `json:"connShortLink,omitempty"`
|
||||
}
|
||||
|
||||
// Contact is a person connected to the bot.
|
||||
Contact struct {
|
||||
ContactID int64 `json:"contactId"`
|
||||
}
|
||||
|
||||
// NewChatItems is the record of a newChatItems event: messages
|
||||
// received, or sent from this profile elsewhere.
|
||||
NewChatItems struct {
|
||||
ChatItems []AChatItem `json:"chatItems"`
|
||||
}
|
||||
|
||||
// ContactConnected is the record of a contactConnected event.
|
||||
ContactConnected struct {
|
||||
Contact Contact `json:"contact"`
|
||||
}
|
||||
|
||||
// AChatItem is one message together with the chat it belongs to.
|
||||
AChatItem struct {
|
||||
ChatInfo struct {
|
||||
Type string `json:"type"`
|
||||
Contact *Contact `json:"contact,omitempty"`
|
||||
} `json:"chatInfo"`
|
||||
ChatItem struct {
|
||||
ChatDir tagged `json:"chatDir"`
|
||||
Meta struct {
|
||||
ItemID int64 `json:"itemId"`
|
||||
} `json:"meta"`
|
||||
Content struct {
|
||||
Type string `json:"type"`
|
||||
MsgContent *MsgContent `json:"msgContent,omitempty"`
|
||||
} `json:"content"`
|
||||
} `json:"chatItem"`
|
||||
}
|
||||
|
||||
// MsgContent is a message body. Only "text" is sent or read here.
|
||||
MsgContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// AddressSettings configures the bot's long-term address.
|
||||
AddressSettings struct {
|
||||
BusinessAddress bool `json:"businessAddress"`
|
||||
AutoAccept *AutoAccept `json:"autoAccept,omitempty"`
|
||||
AutoReply *MsgContent `json:"autoReply,omitempty"`
|
||||
}
|
||||
|
||||
// AutoAccept makes the chat client accept every contact request
|
||||
// to the address itself, when present in AddressSettings.
|
||||
AutoAccept struct {
|
||||
AcceptIncognito bool `json:"acceptIncognito"`
|
||||
}
|
||||
|
||||
composedMessage struct {
|
||||
QuotedItemID int64 `json:"quotedItemId,omitempty"`
|
||||
MsgContent MsgContent `json:"msgContent"`
|
||||
Mentions map[string]int64 `json:"mentions"`
|
||||
}
|
||||
|
||||
envelope struct {
|
||||
CorrID string `json:"corrId,omitempty"`
|
||||
Resp json.RawMessage `json:"resp"`
|
||||
}
|
||||
|
||||
command struct {
|
||||
CorrID string `json:"corrId"`
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
|
||||
cmdError struct {
|
||||
ChatError struct {
|
||||
Type string `json:"type"`
|
||||
ErrorType *tagged `json:"errorType,omitempty"`
|
||||
StoreError *tagged `json:"storeError,omitempty"`
|
||||
AgentError *tagged `json:"agentError,omitempty"`
|
||||
} `json:"chatError"`
|
||||
}
|
||||
|
||||
tagged struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
)
|
||||
|
||||
// Message is a text message a contact sent to the bot.
|
||||
type Message struct {
|
||||
ContactID int64
|
||||
ItemID int64
|
||||
Text string
|
||||
}
|
||||
|
||||
// Message returns the text message a contact sent in a direct chat, and
|
||||
// false for anything else: group messages, files, the bot's own
|
||||
// messages, and the event items the client records in a chat.
|
||||
func (a AChatItem) Message() (Message, bool) {
|
||||
item := a.ChatItem
|
||||
|
||||
if a.ChatInfo.Type != "direct" || a.ChatInfo.Contact == nil ||
|
||||
item.ChatDir.Type != "directRcv" || item.Content.Type != "rcvMsgContent" ||
|
||||
item.Content.MsgContent == nil || item.Content.MsgContent.Type != "text" {
|
||||
return Message{}, false
|
||||
}
|
||||
|
||||
return Message{
|
||||
ContactID: a.ChatInfo.Contact.ContactID,
|
||||
ItemID: item.Meta.ItemID,
|
||||
Text: item.Content.MsgContent.Text,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Command strings. Their syntax is documented per command in the
|
||||
// simplex-chat repository, bots/api/COMMANDS.md.
|
||||
|
||||
const cmdShowActiveUser = "/user"
|
||||
|
||||
func cmdShowAddress(userID int64) string {
|
||||
return "/_show_address " + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func cmdCreateAddress(userID int64) string {
|
||||
return "/_address " + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encoding address settings: %w", err)
|
||||
}
|
||||
|
||||
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil
|
||||
}
|
||||
|
||||
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
|
||||
b, err := json.Marshal([]composedMessage{{
|
||||
QuotedItemID: quotedItemID,
|
||||
MsgContent: MsgContent{Type: "text", Text: text},
|
||||
Mentions: map[string]int64{},
|
||||
}})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encoding message: %w", err)
|
||||
}
|
||||
|
||||
return "/_send @" + strconv.FormatInt(contactID, 10) + " json " + string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package simplex_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||
)
|
||||
|
||||
// newChatItems holds one message the bot must answer and four it must
|
||||
// not, each shaped as the chat client sends it.
|
||||
const newChatItems = `{"type":"newChatItems","user":{"userId":1},"chatItems":[
|
||||
{"chatInfo":{"type":"direct","contact":{"contactId":3,"localDisplayName":"alice"}},
|
||||
"chatItem":{"chatDir":{"type":"directRcv"},
|
||||
"meta":{"itemId":41,"itemText":"2 + 2","itemEdited":false},
|
||||
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}},
|
||||
"mentions":{},"reactions":[]}},
|
||||
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||
"chatItem":{"chatDir":{"type":"directSnd"},"meta":{"itemId":42},
|
||||
"content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"4"}}}},
|
||||
{"chatInfo":{"type":"group","groupInfo":{"groupId":9}},
|
||||
"chatItem":{"chatDir":{"type":"groupRcv","groupMember":{"groupMemberId":5}},
|
||||
"meta":{"itemId":43},
|
||||
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"1 + 1"}}}},
|
||||
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||
"chatItem":{"chatDir":{"type":"directRcv"},"meta":{"itemId":44},
|
||||
"content":{"type":"rcvMsgContent","msgContent":{"type":"file","text":"3 * 3"}}}},
|
||||
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||
"chatItem":{"chatDir":{"type":"directRcv"},"meta":{"itemId":45},
|
||||
"content":{"type":"rcvDirectEvent","rcvDirectEvent":{"type":"contactDeleted"}}}}
|
||||
]}`
|
||||
|
||||
// TestMessage: only a text message a contact sent in a direct chat is a
|
||||
// message to answer. Answering the bot's own messages would loop.
|
||||
func TestMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var r simplex.NewChatItems
|
||||
|
||||
err := json.Unmarshal([]byte(newChatItems), &r)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding: %v", err)
|
||||
}
|
||||
|
||||
var got []simplex.Message
|
||||
|
||||
for _, item := range r.ChatItems {
|
||||
if msg, ok := item.Message(); ok {
|
||||
got = append(got, msg)
|
||||
}
|
||||
}
|
||||
|
||||
want := simplex.Message{ContactID: 3, ItemID: 41, Text: "2 + 2"}
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Errorf("messages = %+v, want only %+v", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user