Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All user-visible bugs and enhancements should be recorded here.

## Unreleased

### Fixed

- `xurl chat add-members` no longer fails with "Invalid action signature" on a group whose message timer (`message_ttl_ms`) or screen-capture blocking setting (`screen_capture_blocking_enabled`) has been set. The signed group state left both out; it now carries the values from the conversation.

## v1.3.2 - 2026-09-23

### Fixed
Expand Down
16 changes: 9 additions & 7 deletions cli/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -1636,13 +1636,15 @@ func (s *chatSession) addGroupMembers(conversationID string, newMembers []string
return err
}
prepared, err := s.chat.PrepareGroupMembersChange(chatxdk.GroupMembersChangeParams{
PublicKeys: inputs,
ConversationID: api.ChatConversationEventID(conversationID),
NewMemberIDs: newIDs,
CurrentMemberIDs: meta.MemberIDs,
CurrentAdminIDs: meta.AdminIDs,
CurrentTitle: meta.GroupName,
CurrentAvatarURL: meta.GroupAvatarURL,
PublicKeys: inputs,
ConversationID: api.ChatConversationEventID(conversationID),
NewMemberIDs: newIDs,
CurrentMemberIDs: meta.MemberIDs,
CurrentAdminIDs: meta.AdminIDs,
CurrentTitle: meta.GroupName,
CurrentAvatarURL: meta.GroupAvatarURL,
CurrentTTLMsec: meta.MessageTTLMs,
CurrentScreenCaptureBlockingEnabled: meta.ScreenCaptureBlockingEnabled,
})
if err != nil {
return fmt.Errorf("failed to prepare member change: %w", err)
Expand Down
69 changes: 69 additions & 0 deletions cli/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,75 @@ func TestPreparedChangeToRequestShape(t *testing.T) {
assert.False(t, has)
}

// addMembersStubClient serves the requests addGroupMembers makes and records
// the body it posts.
type addMembersStubClient struct {
api.Client
conversation string
publicKey string
posted string
}

func (c *addMembersStubClient) SendRequest(opts api.RequestOptions) (json.RawMessage, error) {
switch {
case opts.Method == "GET" && opts.Endpoint == "/2/chat/conversations/g100":
return json.RawMessage(c.conversation), nil
case opts.Method == "GET" && strings.HasSuffix(opts.Endpoint, "/public_keys"):
return json.RawMessage(fmt.Sprintf(`{"data":[{"public_key_version":"1","public_key":%q}]}`, c.publicKey)), nil
case opts.Method == "POST" && opts.Endpoint == "/2/chat/conversations/g100/members":
c.posted = opts.Data
return json.RawMessage(`{"data":{}}`), nil
}
return nil, fmt.Errorf("unexpected request %s %s", opts.Method, opts.Endpoint)
}

func TestAddGroupMembersSignsGroupSettings(t *testing.T) {
// The member-add signature covers the group's current settings. Its signed
// payload ends with {message_ttl_ms|null},{conversation key version},
// {screen_capture_blocking_enabled|null}, and must match the conversation.
tests := []struct {
name string
settings string
want string
}{
{"settings never set", ``, "null,%s,null"},
{"timer on, screen capture off", `,"message_ttl_ms":86400000,"screen_capture_blocking_enabled":false`, "86400000,%s,false"},
{"screen capture on", `,"screen_capture_blocking_enabled":true`, "null,%s,true"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chat := chatxdk.New()
defer chat.Close()
keys, err := chat.GenerateKeypairs()
require.NoError(t, err)
require.NoError(t, chat.SetIdentity("42", "1"))

client := &addMembersStubClient{
conversation: `{"data":{"id":"g100","type":"group","member_ids":["42","7"],"admin_ids":["42"]` + tt.settings + `}}`,
publicKey: keys.PublicKey.PublicKey,
}
s := &chatSession{chat: chat, client: client, userID: "42", usernames: map[string]string{}}
require.NoError(t, s.addGroupMembers("g100", []string{"300"}, true))

var body struct {
ConversationKeyVersion string `json:"conversation_key_version"`
ActionSignatures []struct {
SignaturePayload string `json:"signature_payload"`
} `json:"action_signatures"`
}
require.NoError(t, json.Unmarshal([]byte(client.posted), &body))
var memberAdd string
for _, sig := range body.ActionSignatures {
if strings.HasPrefix(sig.SignaturePayload, "GroupChangeEvent.GroupMemberAddChange,") {
memberAdd = sig.SignaturePayload
}
}
require.NotEmpty(t, memberAdd, "no member-add signature in the request")
assert.True(t, strings.HasSuffix(memberAdd, ","+fmt.Sprintf(tt.want, body.ConversationKeyVersion)), memberAdd)
})
}
}

func TestChatConversationPathHelpers(t *testing.T) {
assert.Equal(t, "1-2", api.ChatConversationPathID("1:2"))
assert.Equal(t, "1:2", api.ChatConversationEventID("1-2"))
Expand Down
Loading