HTTP API: server, credential and the list of chats (closes #4)
check / check (push) Successful in 1m1s

The bot now serves an HTTP API on PORT (default 8080) beside the chat
client. Every request needs the credential read at startup from the
file named by API_TOKEN_FILE, sent as a bearer token; without one,
every request is refused. Responses carry the security headers, bodies
are capped at 64 KiB and each request's work at 10 seconds.

GET /api/v1/chats lists the bot's contacts from the chat client's
/_contacts command, ordered by id, and marks the contacts who deleted
their chat with the bot, which the chat client keeps listing. bot.Run
starts the API after set-up and stops it within 5 seconds; a listener
failure ends the bot as a chat client failure does.

Model: opus-5-5
This commit is contained in:
clawbot
2026-09-29 00:37:57 +00:00
parent 09a5caa8ab
commit 17d6be9fe1
17 changed files with 908 additions and 35 deletions
+12
View File
@@ -168,6 +168,18 @@ func (c *Client) SetAddressSettings(
return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil)
}
// Contacts returns the user's contacts: everyone it has a direct chat
// with.
func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error) {
var r struct {
Contacts []Contact `json:"contacts"`
}
err := c.command(ctx, cmdListContacts(userID), TypeContactsList, &r)
return r.Contacts, err
}
// 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.
+46
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
@@ -43,6 +44,19 @@ const (
contactConnected = `{"type":"contactConnected","user":{"userId":1},
"contact":{"contactId":3,"localDisplayName":"alice"}}`
// Two contacts with the same display name, which the chat client
// tells apart by the local name it gives the second. The second has
// deleted its chat with the bot, and is still listed.
contactsList = `{"type":"contactsList","user":{"userId":1},"contacts":[
{"contactId":3,"localDisplayName":"tester","profile":{"profileId":3,
"displayName":"tester","fullName":"","localAlias":""},
"activeConn":{"connId":2,"connStatus":{"type":"ready"}},
"contactUsed":true,"contactStatus":"active","chatDeleted":false},
{"contactId":4,"localDisplayName":"tester_1","profile":{"profileId":4,
"displayName":"tester","fullName":"","localAlias":""},
"activeConn":{"connId":3,"connStatus":{"type":"deleted"}},
"contactUsed":true,"contactStatus":"deleted","chatDeleted":false}]}`
)
// fakeChat stands in for the chat client's API. It answers each command
@@ -220,6 +234,38 @@ func TestAddressSetup(t *testing.T) {
}
}
// TestContacts: the contacts of the given user come back with their
// ids, display names and whether they are deleted.
func TestContacts(t *testing.T) {
t.Parallel()
f, url := newFakeChat(t, map[string]string{"/_contacts": contactsList})
c, ctx := dial(t, url, nil)
contacts, err := c.Contacts(ctx, 1)
if err != nil {
t.Fatalf("Contacts: %v", err)
}
if got := f.next(t); got != "/_contacts 1" {
t.Errorf("command = %s, want /_contacts 1", got)
}
tester := simplex.Profile{DisplayName: "tester"}
want := []simplex.Contact{
{ContactID: 3, Profile: tester, Status: "active"},
{ContactID: 4, Profile: tester, Status: "deleted"},
}
if !slices.Equal(contacts, want) {
t.Fatalf("Contacts = %+v\nwant %+v", contacts, want)
}
if contacts[0].Deleted() || !contacts[1].Deleted() {
t.Error("Deleted must be false for contact 3 and true for contact 4")
}
}
// TestRefusedCommand: a command the chat client refuses is an error
// that names the reason.
func TestRefusedCommand(t *testing.T) {
+23 -5
View File
@@ -14,6 +14,7 @@ const (
TypeUserContactLink = "userContactLink"
TypeUserContactLinkCreated = "userContactLinkCreated"
TypeUserContactLinkUpdated = "userContactLinkUpdated"
TypeContactsList = "contactsList"
TypeNewChatItems = "newChatItems"
TypeContactConnected = "contactConnected"
TypeChatCmdError = "chatCmdError"
@@ -46,10 +47,14 @@ func (e Event) Decode(v any) error {
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"`
UserID int64 `json:"userId"`
Profile Profile `json:"profile"`
}
// Profile is how the bot or a contact presents itself. Nothing
// makes a display name unique.
Profile struct {
DisplayName string `json:"displayName"`
}
// ConnLink is a SimpleX link. The short form is what people share;
@@ -61,7 +66,9 @@ type (
// Contact is a person connected to the bot.
Contact struct {
ContactID int64 `json:"contactId"`
ContactID int64 `json:"contactId"`
Profile Profile `json:"profile"`
Status string `json:"contactStatus"`
}
// NewChatItems is the record of a newChatItems event: messages
@@ -142,6 +149,13 @@ type (
}
)
// Deleted reports whether the contact is gone, as it is once the person
// deletes their chat with the bot. The chat client still lists such a
// contact, with its chat, but nothing more reaches them.
func (c Contact) Deleted() bool {
return c.Status != "active"
}
// Message is a text message a contact sent to the bot.
type Message struct {
ContactID int64
@@ -190,6 +204,10 @@ func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) {
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil
}
func cmdListContacts(userID int64) string {
return "/_contacts " + strconv.FormatInt(userID, 10)
}
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
b, err := json.Marshal([]composedMessage{{
QuotedItemID: quotedItemID,