Files
clawbot 16649e0f2f
check / check (push) Successful in 54s
A SimpleX Chat bot that answers arithmetic (closes #1)
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
2026-09-26 22:01:51 +00:00

150 lines
3.2 KiB
Go

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))
}