diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index 5dd5972..91222a8 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -250,7 +250,7 @@ func (client *Client) ListChannels() ( // GetMembers returns members of a channel. func (client *Client) GetMembers( channel string, -) ([]string, error) { +) ([]Member, error) { name := strings.TrimPrefix(channel, "#") data, err := client.do( @@ -263,7 +263,7 @@ func (client *Client) GetMembers( return nil, err } - var members []string + var members []Member err = json.Unmarshal(data, &members) if err != nil { diff --git a/internal/cli/api/client_test.go b/internal/cli/api/client_test.go index 8a42cd1..ea3a306 100644 --- a/internal/cli/api/client_test.go +++ b/internal/cli/api/client_test.go @@ -7,6 +7,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" + "strconv" "strings" "testing" @@ -56,9 +58,14 @@ func newSessionServer(t *testing.T) *httptest.Server { return } - _, _ = io.WriteString( - writer, `{"id":1,"nick":"alice","channels":[]}`, - ) + _, _ = io.WriteString(writer, `{ + "id": 1, + "nick": "alice", + "channels": [ + {"id": 1, "name": "#general", "topic": "Welcome!"}, + {"id": 2, "name": "#dev", "topic": ""} + ] + }`) }) server := httptest.NewServer(mux) @@ -67,6 +74,176 @@ func newSessionServer(t *testing.T) *httptest.Server { return server } +// newClientForPath starts a server that answers GET path with +// body, and returns a client for it. +func newClientForPath(t *testing.T, path, body string) *api.Client { + t.Helper() + + mux := http.NewServeMux() + + mux.HandleFunc("GET "+path, func( + writer http.ResponseWriter, _ *http.Request, + ) { + _, _ = io.WriteString(writer, body) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + return api.NewClient(server.URL) +} + +func TestGetStateDecodesChannels(t *testing.T) { + t.Parallel() + + client := api.NewClient(newSessionServer(t).URL) + + _, err := client.CreateSession("alice") + if err != nil { + t.Fatalf("create session: %v", err) + } + + state, err := client.GetState() + if err != nil { + t.Fatalf("state: %v", err) + } + + want := []api.Channel{ + {Name: "#general", Topic: "Welcome!"}, + {Name: "#dev", Topic: ""}, + } + if !slices.Equal(state.Channels, want) { + t.Fatalf("channels: got %+v, want %+v", state.Channels, want) + } +} + +func TestListChannelsDecodesNamesAndTopics(t *testing.T) { + t.Parallel() + + client := newClientForPath(t, "/api/v1/channels", `[ + {"id": 1, "name": "#general", "topic": "Welcome!"}, + {"id": 2, "name": "#dev", "topic": "Development discussion"} + ]`) + + channels, err := client.ListChannels() + if err != nil { + t.Fatalf("list channels: %v", err) + } + + want := []api.Channel{ + {Name: "#general", Topic: "Welcome!"}, + {Name: "#dev", Topic: "Development discussion"}, + } + if !slices.Equal(channels, want) { + t.Fatalf("channels: got %+v, want %+v", channels, want) + } +} + +func TestGetMembersDecodesMemberObjects(t *testing.T) { + t.Parallel() + + client := newClientForPath(t, "/api/v1/channels/general/members", `[ + { + "id": 1, "nick": "alice", "username": "alice", + "hostname": "host.example.com", + "isOperator": true, "isVoiced": false, + "lastSeen": "2026-02-10T20:00:00Z" + }, + { + "id": 2, "nick": "bob", "username": "bob", + "hostname": "host.example.com", + "isOperator": false, "isVoiced": true, + "lastSeen": "2026-02-10T20:00:00Z" + }, + { + "id": 3, "nick": "Carol", "username": "carol", + "hostname": "host.example.com", + "isOperator": false, "isVoiced": false, + "lastSeen": "2026-02-10T20:00:00Z" + } + ]`) + + members, err := client.GetMembers("#general") + if err != nil { + t.Fatalf("get members: %v", err) + } + + want := []api.Member{ + {Nick: "alice", IsOperator: true, IsVoiced: false}, + {Nick: "bob", IsOperator: false, IsVoiced: true}, + {Nick: "Carol", IsOperator: false, IsVoiced: false}, + } + if !slices.Equal(members, want) { + t.Fatalf("members: got %+v, want %+v", members, want) + } +} + +// TestPollMessagesAdvancesCursor serves the queue as the server +// does: entries after the cursor, and last_id as the new cursor. +func TestPollMessagesAdvancesCursor(t *testing.T) { + t.Parallel() + + const lastQueueID = 42 + + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/v1/messages", func( + writer http.ResponseWriter, request *http.Request, + ) { + after := request.URL.Query().Get("after") + if after == strconv.Itoa(lastQueueID) { + _, _ = io.WriteString( + writer, `{"messages": [], "last_id": 42}`, + ) + + return + } + + _, _ = io.WriteString(writer, `{ + "messages": [{ + "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], + "ts": "2026-02-10T20:00:01.234567891Z", + "meta": {} + }], + "last_id": 42 + }`) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client := api.NewClient(server.URL) + + first, err := client.PollMessages(0, 0) + if err != nil { + t.Fatalf("first poll: %v", err) + } + + if first.LastID != lastQueueID || len(first.Messages) != 1 { + t.Fatalf( + "first poll: got last ID %d and %d messages, "+ + "want %d and 1", + first.LastID, len(first.Messages), lastQueueID, + ) + } + + second, err := client.PollMessages(first.LastID, 0) + if err != nil { + t.Fatalf("second poll: %v", err) + } + + if len(second.Messages) != 0 { + t.Fatalf( + "second poll: got %d messages again, want none", + len(second.Messages), + ) + } +} + func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { t.Parallel() diff --git a/internal/cli/api/types.go b/internal/cli/api/types.go index 8c7e50c..e785020 100644 --- a/internal/cli/api/types.go +++ b/internal/cli/api/types.go @@ -16,9 +16,9 @@ type SessionResponse struct { // StateResponse is the response from GET /api/v1/state. type StateResponse struct { - ID int64 `json:"id"` - Nick string `json:"nick"` - Channels []string `json:"channels"` + ID int64 `json:"id"` + Nick string `json:"nick"` + Channels []Channel `json:"channels"` } // Message represents a neoirc message envelope. @@ -53,12 +53,19 @@ func (m *Message) BodyLines() []string { } } -// Channel represents a channel in the list response. +// Channel is a channel as GET /api/v1/channels and +// GET /api/v1/state describe it. type Channel struct { - Name string `json:"name"` - Topic string `json:"topic"` - Members int `json:"members"` - CreatedAt string `json:"createdAt"` + Name string `json:"name"` + Topic string `json:"topic"` +} + +// Member is one entry of the response from +// GET /api/v1/channels/{name}/members. +type Member struct { + Nick string `json:"nick"` + IsOperator bool `json:"isOperator"` + IsVoiced bool `json:"isVoiced"` } // ServerInfo is the response from GET /api/v1/server. @@ -72,7 +79,7 @@ type ServerInfo struct { // MessagesResponse wraps polling results. type MessagesResponse struct { Messages []Message `json:"messages"` - LastID int64 `json:"lastId"` + LastID int64 `json:"last_id"` //nolint:tagliatelle } // PollResult wraps the poll response including the cursor. diff --git a/internal/cli/app.go b/internal/cli/app.go index ea53b15..85df5c8 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -434,16 +434,9 @@ func (a *App) cmdTopic(args string) { return } + // The HTTP API has no topic query: a TOPIC needs text. if args == "" { - err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: irc.CmdTopic, - To: target, - }) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Topic query failed: %v", err, - )) - } + a.ui.AddStatus("[red]Usage: /topic ") return } @@ -487,12 +480,32 @@ func (a *App) cmdNames() { return } + nicks := make([]string, 0, len(members)) + + for _, member := range members { + nicks = append(nicks, memberPrefix(member)+member.Nick) + } + a.ui.AddLine(target, fmt.Sprintf( "[cyan]*** Members of %s: %s", - target, strings.Join(members, " "), + target, strings.Join(nicks, " "), )) } +// memberPrefix returns "@" for an operator, "+" for a voiced +// member, and "" for anyone else. +func memberPrefix(member api.Member) string { + if member.IsOperator { + return "@" + } + + if member.IsVoiced { + return "+" + } + + return "" +} + func (a *App) cmdList() { a.mu.Lock() connected := a.connected @@ -517,8 +530,7 @@ func (a *App) cmdList() { for _, ch := range channels { a.ui.AddStatus(fmt.Sprintf( - " %s (%d members) %s", - ch.Name, ch.Members, ch.Topic, + " %s %s", ch.Name, ch.Topic, )) } @@ -676,7 +688,7 @@ func (a *App) cmdHelp() { " /part [#chan] — Leave channel", " /msg — Send DM", " /query — Open DM window", - " /topic [text] — View/set topic", + " /topic — Set topic", " /names — List channel members", " /list — List channels", " /who [#channel] — List users in channel",