HTTP API: server, credential and the list of chats (closes #4)
check / check (push) Successful in 1m13s
check / check (push) Successful in 1m13s
The bot now serves an HTTP API on `PORT` (default 8080) beside the chat client, whose WebSocket stays on 127.0.0.1 inside the container. Every request needs `Authorization: Bearer` with the credential from the file named by `API_TOKEN_FILE`, compared in constant time; with no credential configured every request is refused, `OPTIONS *` included. `GET /api/v1/chats` lists the bot's chats. Responses carry the security headers from the repository policies; bodies, requests and the server are time- and size-bounded. The chat client stops only after the API has finished its requests. Disclosures: `contact_deleted` is an extra field; 404 and 405 answer in JSON; requests net/http cannot parse are refused by net/http without the security headers; three gosec findings are suppressed as false positives. Model: opus-5-5
This commit was merged in pull request #12.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user