Compare commits

...

6 Commits

Author SHA1 Message Date
morph027
a09805236a
Merge branch 'master' into GHSA-72hv-8253-57qq-bump-jackson-databind-to-2.21.1 2026-03-08 15:09:58 +01:00
AsamK
b498d2050a Bump version to 0.14.1 2026-03-08 12:55:39 +01:00
AsamK
27a722dc75 Add flags to update group member labels 2026-03-08 12:23:26 +01:00
AsamK
7014f629fe Show member labels in listGroups command 2026-03-08 11:55:19 +01:00
AsamK
30b57bdb3d Update graalvm build tools 2026-03-08 08:41:44 +01:00
AsamK
b94162afbc Enable spqr capability 2026-03-07 15:58:01 +01:00
30 changed files with 344 additions and 80 deletions

View File

@ -1,6 +1,15 @@
# Changelog # Changelog
## [Unreleased] ## [0.14.1] - 2026-03-08
### Added
- Added isArchived to contact json output (Thanks @moppman)
- Added support for group member labels
### Fixed
- Adapt registration to signal server changes
## [0.14.0] - 2026-03-01 ## [0.14.0] - 2026-03-01

View File

@ -3,12 +3,12 @@ plugins {
application application
eclipse eclipse
`check-lib-versions` `check-lib-versions`
id("org.graalvm.buildtools.native") version "0.11.4" id("org.graalvm.buildtools.native") version "0.11.5"
} }
allprojects { allprojects {
group = "org.asamk" group = "org.asamk"
version = "0.14.1-SNAPSHOT" version = "0.14.1"
} }
java { java {

View File

@ -598,6 +598,12 @@ pub enum CliCommands {
#[arg(short = 'e', long)] #[arg(short = 'e', long)]
expiration: Option<u32>, expiration: Option<u32>,
#[arg(long = "member-label-emoji")]
member_label_emoji: Option<String>,
#[arg(long = "member-label")]
member_label: Option<String>,
}, },
UpdateProfile { UpdateProfile {
#[arg(long = "given-name")] #[arg(long = "given-name")]

View File

@ -460,6 +460,8 @@ pub trait Rpc {
#[allow(non_snake_case)] setPermissionEditDetails: Option<String>, #[allow(non_snake_case)] setPermissionEditDetails: Option<String>,
#[allow(non_snake_case)] setPermissionSendMessages: Option<String>, #[allow(non_snake_case)] setPermissionSendMessages: Option<String>,
expiration: Option<u32>, expiration: Option<u32>,
#[allow(non_snake_case)] memberLabelEmoji: Option<String>,
#[allow(non_snake_case)] memberLabel: Option<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateProfile", param_kind = map)] #[method(name = "updateProfile", param_kind = map)]

View File

@ -535,6 +535,8 @@ async fn handle_command(
set_permission_edit_details, set_permission_edit_details,
set_permission_send_messages, set_permission_send_messages,
expiration, expiration,
member_label_emoji,
member_label,
} => { } => {
client client
.update_group( .update_group(
@ -568,6 +570,8 @@ async fn handle_command(
GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(), GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(),
}), }),
expiration, expiration,
member_label_emoji,
member_label,
) )
.await .await
} }

View File

@ -45,6 +45,9 @@
<content_attribute id="social-chat">intense</content_attribute> <content_attribute id="social-chat">intense</content_attribute>
</content_rating> </content_rating>
<releases> <releases>
<release version="0.14.1" date="2026-03-08">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.1</url>
</release>
<release version="0.14.0" date="2026-03-01"> <release version="0.14.0" date="2026-03-01">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.0</url> <url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.0</url>
</release> </release>

View File

@ -12,10 +12,9 @@ public record Group(
String title, String title,
String description, String description,
GroupInviteLinkUrl groupInviteLinkUrl, GroupInviteLinkUrl groupInviteLinkUrl,
Set<RecipientAddress> members, Set<GroupMember> members,
Set<RecipientAddress> pendingMembers, Set<RecipientAddress> pendingMembers,
Set<RecipientAddress> requestingMembers, Set<RecipientAddress> requestingMembers,
Set<RecipientAddress> adminMembers,
Set<RecipientAddress> bannedMembers, Set<RecipientAddress> bannedMembers,
boolean isBlocked, boolean isBlocked,
int messageExpirationTimer, int messageExpirationTimer,
@ -37,8 +36,7 @@ public record Group(
groupInfo.getGroupInviteLink(), groupInfo.getGroupInviteLink(),
groupInfo.getMembers() groupInfo.getMembers()
.stream() .stream()
.map(recipientStore::resolveRecipientAddress) .map(m -> org.asamk.signal.manager.api.GroupMember.from(m, recipientStore))
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
groupInfo.getPendingMembers() groupInfo.getPendingMembers()
.stream() .stream()
@ -50,11 +48,6 @@ public record Group(
.map(recipientStore::resolveRecipientAddress) .map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress) .map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
groupInfo.getAdminMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getBannedMembers() groupInfo.getBannedMembers()
.stream() .stream()
.map(recipientStore::resolveRecipientAddress) .map(recipientStore::resolveRecipientAddress)

View File

@ -0,0 +1,14 @@
package org.asamk.signal.manager.api;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.groups.GroupMemberInfo;
public record GroupMember(
RecipientAddress recipientAddress, boolean isAdmin, String labelEmoji, String label
) {
public static GroupMember from(final GroupMemberInfo memberInfo, final RecipientAddressResolver recipientStore) {
return new GroupMember(recipientStore.resolveRecipientAddress(memberInfo.getRecipientId())
.toApiRecipientAddress(), memberInfo.isAdmin(), memberInfo.labelEmoji(), memberInfo.labelString());
}
}

View File

@ -19,6 +19,8 @@ public class UpdateGroup {
private final String avatarFile; private final String avatarFile;
private final Integer expirationTimer; private final Integer expirationTimer;
private final Boolean isAnnouncementGroup; private final Boolean isAnnouncementGroup;
private final String labelEmoji;
private final String labelString;
private UpdateGroup(final Builder builder) { private UpdateGroup(final Builder builder) {
name = builder.name; name = builder.name;
@ -36,6 +38,8 @@ public class UpdateGroup {
avatarFile = builder.avatarFile; avatarFile = builder.avatarFile;
expirationTimer = builder.expirationTimer; expirationTimer = builder.expirationTimer;
isAnnouncementGroup = builder.isAnnouncementGroup; isAnnouncementGroup = builder.isAnnouncementGroup;
labelEmoji = builder.labelEmoji;
labelString = builder.labelString;
} }
public static Builder newBuilder() { public static Builder newBuilder() {
@ -57,7 +61,9 @@ public class UpdateGroup {
copy.editDetailsPermission, copy.editDetailsPermission,
copy.avatarFile, copy.avatarFile,
copy.expirationTimer, copy.expirationTimer,
copy.isAnnouncementGroup); copy.isAnnouncementGroup,
copy.labelEmoji,
copy.labelString);
} }
public static Builder newBuilder( public static Builder newBuilder(
@ -75,7 +81,9 @@ public class UpdateGroup {
final GroupPermission editDetailsPermission, final GroupPermission editDetailsPermission,
final String avatarFile, final String avatarFile,
final Integer expirationTimer, final Integer expirationTimer,
final Boolean isAnnouncementGroup final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) { ) {
return new Builder(name, return new Builder(name,
description, description,
@ -91,7 +99,9 @@ public class UpdateGroup {
editDetailsPermission, editDetailsPermission,
avatarFile, avatarFile,
expirationTimer, expirationTimer,
isAnnouncementGroup); isAnnouncementGroup,
labelEmoji,
labelString);
} }
public String getName() { public String getName() {
@ -154,6 +164,14 @@ public class UpdateGroup {
return isAnnouncementGroup; return isAnnouncementGroup;
} }
public String getLabelEmoji() {
return labelEmoji;
}
public String getLabelString() {
return labelString;
}
public static final class Builder { public static final class Builder {
private String name; private String name;
@ -171,6 +189,8 @@ public class UpdateGroup {
private String avatarFile; private String avatarFile;
private Integer expirationTimer; private Integer expirationTimer;
private Boolean isAnnouncementGroup; private Boolean isAnnouncementGroup;
private String labelEmoji;
private String labelString;
private Builder() { private Builder() {
} }
@ -190,7 +210,9 @@ public class UpdateGroup {
final GroupPermission editDetailsPermission, final GroupPermission editDetailsPermission,
final String avatarFile, final String avatarFile,
final Integer expirationTimer, final Integer expirationTimer,
final Boolean isAnnouncementGroup final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) { ) {
this.name = name; this.name = name;
this.description = description; this.description = description;
@ -207,6 +229,8 @@ public class UpdateGroup {
this.avatarFile = avatarFile; this.avatarFile = avatarFile;
this.expirationTimer = expirationTimer; this.expirationTimer = expirationTimer;
this.isAnnouncementGroup = isAnnouncementGroup; this.isAnnouncementGroup = isAnnouncementGroup;
this.labelEmoji = labelEmoji;
this.labelString = labelString;
} }
public Builder withName(final String val) { public Builder withName(final String val) {
@ -284,6 +308,16 @@ public class UpdateGroup {
return this; return this;
} }
public Builder withLabelEmoji(final String val) {
labelEmoji = val;
return this;
}
public Builder withLabelString(final String val) {
labelString = val;
return this;
}
public UpdateGroup build() { public UpdateGroup build() {
return new UpdateGroup(this); return new UpdateGroup(this);
} }

View File

@ -29,7 +29,7 @@ public class ServiceConfig {
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) { public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
final var attachmentBackfill = !isPrimaryDevice; final var attachmentBackfill = !isPrimaryDevice;
final var spqr = !isPrimaryDevice; final var spqr = true;
return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr); return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr);
} }

View File

@ -299,7 +299,9 @@ public class GroupHelper {
final GroupPermission editDetailsPermission, final GroupPermission editDetailsPermission,
final String avatarFile, final String avatarFile,
final Integer expirationTimer, final Integer expirationTimer,
final Boolean isAnnouncementGroup final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException { ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
var group = getGroupForUpdating(groupId); var group = getGroupForUpdating(groupId);
final var avatarBytes = readAvatarBytes(avatarFile); final var avatarBytes = readAvatarBytes(avatarFile);
@ -323,7 +325,9 @@ public class GroupHelper {
editDetailsPermission, editDetailsPermission,
avatarBytes, avatarBytes,
expirationTimer, expirationTimer,
isAnnouncementGroup); isAnnouncementGroup,
labelEmoji,
labelString);
} catch (ConflictException e) { } catch (ConflictException e) {
// Detected conflicting update, refreshing group and trying again // Detected conflicting update, refreshing group and trying again
group = getGroup(groupId, true); group = getGroup(groupId, true);
@ -342,7 +346,9 @@ public class GroupHelper {
editDetailsPermission, editDetailsPermission,
avatarBytes, avatarBytes,
expirationTimer, expirationTimer,
isAnnouncementGroup); isAnnouncementGroup,
labelEmoji,
labelString);
} }
} }
@ -701,7 +707,9 @@ public class GroupHelper {
final GroupPermission editDetailsPermission, final GroupPermission editDetailsPermission,
final byte[] avatarFile, final byte[] avatarFile,
final Integer expirationTimer, final Integer expirationTimer,
final Boolean isAnnouncementGroup final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) throws IOException { ) throws IOException {
SendGroupMessageResults result = null; SendGroupMessageResults result = null;
final var groupV2Helper = context.getGroupV2Helper(); final var groupV2Helper = context.getGroupV2Helper();
@ -758,7 +766,7 @@ public class GroupHelper {
if (admins != null) { if (admins != null) {
final var newAdmins = new HashSet<>(admins); final var newAdmins = new HashSet<>(admins);
newAdmins.retainAll(group.getMembers()); newAdmins.retainAll(group.getMembers());
newAdmins.removeAll(group.getAdminMembers()); newAdmins.removeAll(group.getAdminMemberRecipientIds());
if (!newAdmins.isEmpty()) { if (!newAdmins.isEmpty()) {
for (var admin : newAdmins) { for (var admin : newAdmins) {
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true); var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
@ -771,7 +779,7 @@ public class GroupHelper {
if (removeAdmins != null) { if (removeAdmins != null) {
final var existingRemoveAdmins = new HashSet<>(removeAdmins); final var existingRemoveAdmins = new HashSet<>(removeAdmins);
existingRemoveAdmins.retainAll(group.getAdminMembers()); existingRemoveAdmins.retainAll(group.getAdminMemberRecipientIds());
if (!existingRemoveAdmins.isEmpty()) { if (!existingRemoveAdmins.isEmpty()) {
for (var admin : existingRemoveAdmins) { for (var admin : existingRemoveAdmins) {
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false); var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
@ -830,6 +838,15 @@ public class GroupHelper {
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
} }
if (labelString != null || labelEmoji != null) {
final var selfRecipientId = account.getSelfRecipientId();
final var selfMember = group.getMember(selfRecipientId);
var groupGroupChangePair = groupV2Helper.setMemberLabels(group,
labelEmoji != null ? labelEmoji : selfMember.labelEmoji(),
labelString != null ? labelString : selfMember.labelString());
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
if (name != null || description != null || avatarFile != null) { if (name != null || description != null || avatarFile != null) {
var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile); var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
if (avatarFile != null) { if (avatarFile != null) {
@ -859,7 +876,7 @@ public class GroupHelper {
final GroupInfoV2 groupInfoV2, final GroupInfoV2 groupInfoV2,
final Set<RecipientId> newAdmins final Set<RecipientId> newAdmins
) throws LastGroupAdminException, IOException { ) throws LastGroupAdminException, IOException {
final var currentAdmins = groupInfoV2.getAdminMembers(); final var currentAdmins = groupInfoV2.getAdminMemberRecipientIds();
newAdmins.removeAll(currentAdmins); newAdmins.removeAll(currentAdmins);
newAdmins.retainAll(groupInfoV2.getMembers()); newAdmins.retainAll(groupInfoV2.getMembers());
if (currentAdmins.contains(account.getSelfRecipientId()) if (currentAdmins.contains(account.getSelfRecipientId())
@ -888,7 +905,7 @@ public class GroupHelper {
var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE) var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
.withId(g.getGroupId().serialize()) .withId(g.getGroupId().serialize())
.withName(g.name) .withName(g.name)
.withMembers(g.getMembers() .withMembers(g.getMemberRecipientIds()
.stream() .stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress) .map(context.getRecipientHelper()::resolveSignalServiceAddress)
.toList()); .toList());

View File

@ -533,6 +533,18 @@ class GroupV2Helper {
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
Pair<DecryptedGroup, GroupChangeResponse> setMemberLabels(
GroupInfoV2 groupInfoV2,
String labelEmoji,
String labelString
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var change = groupOperations.createChangeMemberLabel(getSelfAci(),
labelString == null ? "" : labelString,
labelEmoji);
return commitChange(groupInfoV2, change);
}
private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) { private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) {
return switch (state) { return switch (state) {
case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE; case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE;

View File

@ -320,7 +320,9 @@ public class SendHelper {
messageBuilder.withExpiration(g.getMessageExpirationTimer()); messageBuilder.withExpiration(g.getMessageExpirationTimer());
final var message = messageBuilder.build(); final var message = messageBuilder.build();
final var recipients = includeSelf ? g.getMembers() : g.getMembersWithout(account.getSelfRecipientId()); final var recipients = includeSelf
? g.getMemberRecipientIds()
: g.getMembersWithout(account.getSelfRecipientId());
if (g.isAnnouncementGroup() && !g.isAdmin(account.getSelfRecipientId())) { if (g.isAnnouncementGroup() && !g.isAdmin(account.getSelfRecipientId())) {
if (message.getBody().isPresent() if (message.getBody().isPresent()

View File

@ -114,7 +114,7 @@ public class SyncHelper {
if (record instanceof GroupInfoV1 groupInfo) { if (record instanceof GroupInfoV1 groupInfo) {
out.write(new DeviceGroup(groupInfo.getGroupId().serialize(), out.write(new DeviceGroup(groupInfo.getGroupId().serialize(),
Optional.ofNullable(groupInfo.name), Optional.ofNullable(groupInfo.name),
groupInfo.getMembers() groupInfo.getMemberRecipientIds()
.stream() .stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress) .map(context.getRecipientHelper()::resolveSignalServiceAddress)
.toList(), .toList(),

View File

@ -633,7 +633,9 @@ public class ManagerImpl implements Manager {
updateGroup.getEditDetailsPermission(), updateGroup.getEditDetailsPermission(),
updateGroup.getAvatarFile(), updateGroup.getAvatarFile(),
updateGroup.getExpirationTimer(), updateGroup.getExpirationTimer(),
updateGroup.getIsAnnouncementGroup()); updateGroup.getIsAnnouncementGroup(),
updateGroup.getLabelEmoji(),
updateGroup.getLabelString());
} }
@Override @Override

View File

@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
import java.util.Collection;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -24,7 +25,18 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract GroupInviteLinkUrl getGroupInviteLink(); public abstract GroupInviteLinkUrl getGroupInviteLink();
public abstract Set<RecipientId> getMembers(); public abstract Collection<GroupMemberInfo> getMembers();
public Set<RecipientId> getMemberRecipientIds() {
return getMembers().stream().map(GroupMemberInfo::getRecipientId).collect(Collectors.toSet());
}
public GroupMemberInfo getMember(RecipientId recipientId) {
return getMembers().stream()
.filter(member -> member.getRecipientId().equals(recipientId))
.findFirst()
.orElseThrow();
}
public Set<RecipientId> getBannedMembers() { public Set<RecipientId> getBannedMembers() {
return Set.of(); return Set.of();
@ -38,7 +50,7 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
return Set.of(); return Set.of();
} }
public Set<RecipientId> getAdminMembers() { public Set<RecipientId> getAdminMemberRecipientIds() {
return Set.of(); return Set.of();
} }
@ -61,21 +73,23 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract GroupPermission getPermissionSendMessage(); public abstract GroupPermission getPermissionSendMessage();
public Set<RecipientId> getMembersWithout(RecipientId recipientId) { public Set<RecipientId> getMembersWithout(RecipientId recipientId) {
return getMembers().stream().filter(member -> !member.equals(recipientId)).collect(Collectors.toSet()); return getMemberRecipientIds().stream()
.filter(member -> !member.equals(recipientId))
.collect(Collectors.toSet());
} }
public Set<RecipientId> getMembersIncludingPendingWithout(RecipientId recipientId) { public Set<RecipientId> getMembersIncludingPendingWithout(RecipientId recipientId) {
return Stream.concat(getMembers().stream(), getPendingMembers().stream()) return Stream.concat(getMemberRecipientIds().stream(), getPendingMembers().stream())
.filter(member -> !member.equals(recipientId)) .filter(member -> !member.equals(recipientId))
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
public boolean isMember(RecipientId recipientId) { public boolean isMember(RecipientId recipientId) {
return getMembers().contains(recipientId); return getMembers().stream().anyMatch(m -> m.getRecipientId().equals(recipientId));
} }
public boolean isAdmin(RecipientId recipientId) { public boolean isAdmin(RecipientId recipientId) {
return getAdminMembers().contains(recipientId); return getMembers().stream().anyMatch(m -> m.isAdmin() && m.getRecipientId().equals(recipientId));
} }
public boolean isPendingMember(RecipientId recipientId) { public boolean isPendingMember(RecipientId recipientId) {

View File

@ -80,8 +80,8 @@ public final class GroupInfoV1 extends GroupInfo {
return null; return null;
} }
public Set<RecipientId> getMembers() { public Collection<GroupMemberInfo> getMembers() {
return new HashSet<>(members); return members.stream().map(m -> (GroupMemberInfo) new GroupMemberInfoV1(m)).toList();
} }
@Override @Override

View File

@ -3,18 +3,17 @@ package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.api.GroupIdV2; import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.api.GroupInviteLinkUrl; import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.GroupPermission; import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.signal.core.models.ServiceId; import org.signal.core.models.ServiceId;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.storage.protos.groups.AccessControl; import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.Member;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.EnabledState; import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Collection;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -122,14 +121,11 @@ public final class GroupInfoV2 extends GroupInfo {
} }
@Override @Override
public Set<RecipientId> getMembers() { public Collection<GroupMemberInfo> getMembers() {
if (this.group == null) { if (this.group == null) {
return Set.of(); return Set.of();
} }
return group.members.stream() return group.members.stream().map(m -> (GroupMemberInfo) new GroupMemberInfoV2(m, recipientResolver)).toList();
.map(m -> ServiceId.parseOrThrow(m.aciBytes))
.map(recipientResolver::resolveRecipient)
.collect(Collectors.toSet());
} }
@Override @Override
@ -175,16 +171,11 @@ public final class GroupInfoV2 extends GroupInfo {
} }
@Override @Override
public Set<RecipientId> getAdminMembers() { public Set<RecipientId> getAdminMemberRecipientIds() {
if (this.group == null) { return this.getMembers()
return Set.of(); .stream()
} .filter(GroupMemberInfo::isAdmin)
return group.members.stream() .map(GroupMemberInfo::getRecipientId)
.filter(m -> m.role == Member.Role.ADMINISTRATOR)
.map(m -> new RecipientAddress(ServiceId.ACI.parseOrNull(m.aciBytes),
ServiceId.PNI.parseOrNull(m.pniBytes),
null))
.map(recipientResolver::resolveRecipient)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }

View File

@ -0,0 +1,20 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public interface GroupMemberInfo {
RecipientId getRecipientId();
default boolean isAdmin() {
return false;
}
default String labelEmoji() {
return null;
}
default String labelString() {
return null;
}
}

View File

@ -0,0 +1,17 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public class GroupMemberInfoV1 implements GroupMemberInfo {
private final RecipientId recipientId;
public GroupMemberInfoV1(final RecipientId recipientId) {
this.recipientId = recipientId;
}
@Override
public RecipientId getRecipientId() {
return this.recipientId;
}
}

View File

@ -0,0 +1,38 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.signal.core.models.ServiceId;
import org.signal.storageservice.storage.protos.groups.Member;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
public class GroupMemberInfoV2 implements GroupMemberInfo {
private final RecipientResolver recipientResolver;
private final DecryptedMember member;
public GroupMemberInfoV2(final DecryptedMember member, final RecipientResolver recipientResolver) {
this.recipientResolver = recipientResolver;
this.member = member;
}
@Override
public RecipientId getRecipientId() {
return recipientResolver.resolveRecipient(ServiceId.ACI.parseOrThrow(member.aciBytes));
}
@Override
public boolean isAdmin() {
return member.role == Member.Role.ADMINISTRATOR;
}
@Override
public String labelEmoji() {
return member.labelEmoji.isEmpty() ? null : member.labelEmoji;
}
@Override
public String labelString() {
return member.labelString.isEmpty() ? null : member.labelString;
}
}

View File

@ -648,7 +648,7 @@ public class GroupStore {
ON CONFLICT (group_id, recipient_id) DO NOTHING ON CONFLICT (group_id, recipient_id) DO NOTHING
""".formatted(TABLE_GROUP_V1_MEMBER); """.formatted(TABLE_GROUP_V1_MEMBER);
try (final var statement = connection.prepareStatement(sqlInsertMember)) { try (final var statement = connection.prepareStatement(sqlInsertMember)) {
for (final var recipient : groupV1.getMembers()) { for (final var recipient : groupV1.getMemberRecipientIds()) {
statement.setLong(1, internalId); statement.setLong(1, internalId);
statement.setLong(2, recipient.id()); statement.setLong(2, recipient.id());
statement.executeUpdate(); statement.executeUpdate();

View File

@ -133,6 +133,11 @@ public class KyberPreKeyStore implements SignalServiceKyberPreKeyStore {
return getPreKey(keyId) != null; return getPreKey(keyId) != null;
} }
/**
* When we mark Kyber pre-keys used, we want to keep a record of last resort tuples, which are deleted when the key
* itself is deleted from this table via a cascading delete.
* For non-last-resort keys, this method just deletes them like normal.
*/
@Override @Override
public void markKyberPreKeyUsed( public void markKyberPreKeyUsed(
final int kyberPreKeyId, final int kyberPreKeyId,

View File

@ -154,7 +154,7 @@ RESPONSE: `{"jsonrpc":"2.0","result":{"timestamp":999},"id":4}`
--- ---
REQUEST: `{"jsonrpc":"2.0","method":"updateGroup","params":{"groupId":"GROUP_ID=","name":"new group name","members":["+ZZZ"],"link":"enabledWithApproval","setPermissionEditDetails":"only-admins"},"id":"someId"}` REQUEST: `{"jsonrpc":"2.0","method":"updateGroup","params":{"groupId":"GROUP_ID=","name":"new group name","members":["+ZZZ"],"link":"enabledWithApproval","setPermissionEditDetails":"only-admins","memberLabelEmoji":"😀","memberLabel":"My Label"},"id":"someId"}`
RESPONSE: `{"jsonrpc":"2.0","result":{"timestamp":9999},"id":"someId"}` RESPONSE: `{"jsonrpc":"2.0","result":{"timestamp":9999},"id":"someId"}`

View File

@ -754,6 +754,12 @@ Groups where only admins can send messages are also called announcement groups
Set expiration time of messages (seconds). Set expiration time of messages (seconds).
To disable expiration set expiration time to 0. To disable expiration set expiration time to 0.
*--member-label-emoji* EMOJI::
Specify the emoji for the member label.
*--member-label* STRING::
Specify the string for the member label.
=== quitGroup === quitGroup
Send a quit group message to all group members and remove self from member list. Send a quit group message to all group members and remove self from member list.

View File

@ -1,5 +1,7 @@
package org.asamk.signal.commands; package org.asamk.signal.commands;
import com.fasterxml.jackson.annotation.JsonInclude;
import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser; import net.sourceforge.argparse4j.inf.Subparser;
@ -7,6 +9,7 @@ import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.Group; import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupMember;
import org.asamk.signal.manager.api.RecipientAddress; import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.output.JsonWriter; import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter; import org.asamk.signal.output.OutputWriter;
@ -37,33 +40,55 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
subparser.addArgument("-g", "--group-id").help("Specify one or more group IDs to show.").nargs("*"); subparser.addArgument("-g", "--group-id").help("Specify one or more group IDs to show.").nargs("*");
} }
private static Set<String> resolveMembers(Set<RecipientAddress> addresses) { private static Set<String> resolveMembers(Set<GroupMember> addresses) {
return addresses.stream()
.map(m -> m.recipientAddress().getLegacyIdentifier() + (m.isAdmin() ? "{ADMIN}" : "") + (
m.labelEmoji() != null || m.label() != null ? "(" + (
m.labelEmoji() != null ? m.labelEmoji() : ""
) + (
m.label() != null ? m.label() : ""
) + ")" : ""
))
.collect(Collectors.toSet());
}
private static Set<String> resolveMemberAddress(Set<RecipientAddress> addresses) {
return addresses.stream().map(RecipientAddress::getLegacyIdentifier).collect(Collectors.toSet()); return addresses.stream().map(RecipientAddress::getLegacyIdentifier).collect(Collectors.toSet());
} }
private static Set<JsonGroupMember> resolveJsonMembers(Set<RecipientAddress> addresses) { private static Set<JsonGroupMemberAddress> resolveJsonMembers(Set<RecipientAddress> addresses) {
return addresses.stream() return addresses.stream()
.map(address -> new JsonGroupMember(address.number().orElse(null), .map(address -> new JsonGroupMemberAddress(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null))) address.uuid().map(UUID::toString).orElse(null)))
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
private static Set<JsonGroupMember> resolveFullJsonMembers(Set<GroupMember> addresses) {
return addresses.stream().map(member -> {
final var address = member.recipientAddress();
return new JsonGroupMember(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null),
member.isAdmin(),
member.labelEmoji(),
member.label());
}).collect(Collectors.toSet());
}
private static void printGroupPlainText(PlainTextWriter writer, Group group, boolean detailed) { private static void printGroupPlainText(PlainTextWriter writer, Group group, boolean detailed) {
if (detailed) { if (detailed) {
final var groupInviteLink = group.groupInviteLinkUrl(); final var groupInviteLink = group.groupInviteLinkUrl();
writer.println( writer.println(
"Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Admins: {} Banned: {} Message expiration: {} Link: {}", "Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}",
group.groupId().toBase64(), group.groupId().toBase64(),
group.title(), group.title(),
group.description(), group.description(),
group.isMember(), group.isMember(),
group.isBlocked(), group.isBlocked(),
resolveMembers(group.members()), resolveMembers(group.members()),
resolveMembers(group.pendingMembers()), resolveMemberAddress(group.pendingMembers()),
resolveMembers(group.requestingMembers()), resolveMemberAddress(group.requestingMembers()),
resolveMembers(group.adminMembers()), resolveMemberAddress(group.bannedMembers()),
resolveMembers(group.bannedMembers()),
group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s", group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s",
groupInviteLink == null ? '-' : groupInviteLink.getUrl()); groupInviteLink == null ? '-' : groupInviteLink.getUrl());
} else { } else {
@ -96,10 +121,14 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
group.isMember(), group.isMember(),
group.isBlocked(), group.isBlocked(),
group.messageExpirationTimer(), group.messageExpirationTimer(),
resolveJsonMembers(group.members()), resolveFullJsonMembers(group.members()),
resolveJsonMembers(group.pendingMembers()), resolveJsonMembers(group.pendingMembers()),
resolveJsonMembers(group.requestingMembers()), resolveJsonMembers(group.requestingMembers()),
resolveJsonMembers(group.adminMembers()), resolveJsonMembers(group.members()
.stream()
.filter(GroupMember::isAdmin)
.map(GroupMember::recipientAddress)
.collect(Collectors.toSet())),
resolveJsonMembers(group.bannedMembers()), resolveJsonMembers(group.bannedMembers()),
group.permissionAddMember().name(), group.permissionAddMember().name(),
group.permissionEditDetails().name(), group.permissionEditDetails().name(),
@ -125,15 +154,23 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
boolean isBlocked, boolean isBlocked,
int messageExpirationTime, int messageExpirationTime,
Set<JsonGroupMember> members, Set<JsonGroupMember> members,
Set<JsonGroupMember> pendingMembers, Set<JsonGroupMemberAddress> pendingMembers,
Set<JsonGroupMember> requestingMembers, Set<JsonGroupMemberAddress> requestingMembers,
Set<JsonGroupMember> admins, @Deprecated Set<JsonGroupMemberAddress> admins,
Set<JsonGroupMember> banned, Set<JsonGroupMemberAddress> banned,
String permissionAddMember, String permissionAddMember,
String permissionEditDetails, String permissionEditDetails,
String permissionSendMessage, String permissionSendMessage,
String groupInviteLink String groupInviteLink
) {} ) {}
private record JsonGroupMember(String number, String uuid) {} private record JsonGroupMemberAddress(String number, String uuid) {}
private record JsonGroupMember(
String number,
String uuid,
boolean isAdmin,
@JsonInclude(JsonInclude.Include.NON_NULL) String labelEmoji,
@JsonInclude(JsonInclude.Include.NON_NULL) String label
) {}
} }

View File

@ -75,6 +75,8 @@ public class UpdateGroupCommand implements JsonRpcLocalCommand {
.choices("every-member", "only-admins"); .choices("every-member", "only-admins");
subparser.addArgument("-e", "--expiration").type(int.class).help("Set expiration time of messages (seconds)"); subparser.addArgument("-e", "--expiration").type(int.class).help("Set expiration time of messages (seconds)");
subparser.addArgument("--member-label-emoji").help("Specify the emoji for the member label.");
subparser.addArgument("--member-label").help("Specify the string for the member label.");
} }
GroupLinkState getGroupLinkState(String value) throws UserErrorException { GroupLinkState getGroupLinkState(String value) throws UserErrorException {
@ -126,6 +128,8 @@ public class UpdateGroupCommand implements JsonRpcLocalCommand {
var groupAddMemberPermission = getGroupPermission(ns.getString("set-permission-add-member")); var groupAddMemberPermission = getGroupPermission(ns.getString("set-permission-add-member"));
var groupEditDetailsPermission = getGroupPermission(ns.getString("set-permission-edit-details")); var groupEditDetailsPermission = getGroupPermission(ns.getString("set-permission-edit-details"));
var groupSendMessagesPermission = getGroupPermission(ns.getString("set-permission-send-messages")); var groupSendMessagesPermission = getGroupPermission(ns.getString("set-permission-send-messages"));
var memberLabelEmoji = ns.getString("member-label-emoji");
var memberLabelString = ns.getString("member-label");
try { try {
boolean isNewGroup = false; boolean isNewGroup = false;
@ -159,6 +163,8 @@ public class UpdateGroupCommand implements JsonRpcLocalCommand {
.withIsAnnouncementGroup(groupSendMessagesPermission == null .withIsAnnouncementGroup(groupSendMessagesPermission == null
? null ? null
: groupSendMessagesPermission == GroupPermission.ONLY_ADMINS) : groupSendMessagesPermission == GroupPermission.ONLY_ADMINS)
.withLabelEmoji(memberLabelEmoji)
.withLabelString(memberLabelString)
.build()); .build());
if (results != null) { if (results != null) {
if (groupMessageResults == null) { if (groupMessageResults == null) {

View File

@ -12,6 +12,7 @@ import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.Group; import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId; import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl; import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.GroupMember;
import org.asamk.signal.manager.api.GroupNotFoundException; import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupPermission; import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException; import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
@ -834,24 +835,22 @@ public class DbusManagerImpl implements Manager {
final var group = getRemoteObject(groupPath, Signal.Group.class).GetAll("org.asamk.Signal.Group"); final var group = getRemoteObject(groupPath, Signal.Group.class).GetAll("org.asamk.Signal.Group");
final var id = (byte[]) group.get("Id").getValue(); final var id = (byte[]) group.get("Id").getValue();
try { try {
final var admins = new HashSet<>(((List<String>) group.get("Admins").getValue()));
return new Group(GroupId.unknownVersion(id), return new Group(GroupId.unknownVersion(id),
(String) group.get("Name").getValue(), (String) group.get("Name").getValue(),
(String) group.get("Description").getValue(), (String) group.get("Description").getValue(),
GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()), GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
((List<String>) group.get("Members").getValue()).stream() ((List<String>) group.get("Members").getValue()).stream()
.map(m -> new RecipientAddress(m)) .map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null))
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
((List<String>) group.get("PendingMembers").getValue()).stream() ((List<String>) group.get("PendingMembers").getValue()).stream()
.map(m -> new RecipientAddress(m)) .map(RecipientAddress::new)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
((List<String>) group.get("RequestingMembers").getValue()).stream() ((List<String>) group.get("RequestingMembers").getValue()).stream()
.map(m -> new RecipientAddress(m)) .map(RecipientAddress::new)
.collect(Collectors.toSet()),
((List<String>) group.get("Admins").getValue()).stream()
.map(m -> new RecipientAddress(m))
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
((List<String>) group.get("Banned").getValue()).stream() ((List<String>) group.get("Banned").getValue()).stream()
.map(m -> new RecipientAddress(m)) .map(RecipientAddress::new)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
(boolean) group.get("IsBlocked").getValue(), (boolean) group.get("IsBlocked").getValue(),
(int) group.get("MessageExpirationTimer").getValue(), (int) group.get("MessageExpirationTimer").getValue(),

View File

@ -10,6 +10,7 @@ import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.GroupId; import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl; import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.GroupLinkState; import org.asamk.signal.manager.api.GroupLinkState;
import org.asamk.signal.manager.api.GroupMember;
import org.asamk.signal.manager.api.GroupNotFoundException; import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupPermission; import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException; import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
@ -624,7 +625,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
if (group == null) { if (group == null) {
return List.of(); return List.of();
} else { } else {
final var members = group.members(); final var members = group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet());
return getRecipientStrings(members); return getRecipientStrings(members);
} }
} }
@ -1300,13 +1301,20 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
() -> getGroup().messageExpirationTimer(), () -> getGroup().messageExpirationTimer(),
this::setMessageExpirationTime), this::setMessageExpirationTime),
new DbusProperty<>("Members", new DbusProperty<>("Members",
() -> new Variant<>(getRecipientStrings(getGroup().members()), "as")), () -> new Variant<>(getRecipientStrings(getGroup().members()
.stream()
.map(GroupMember::recipientAddress)
.collect(Collectors.toSet())), "as")),
new DbusProperty<>("PendingMembers", new DbusProperty<>("PendingMembers",
() -> new Variant<>(getRecipientStrings(getGroup().pendingMembers()), "as")), () -> new Variant<>(getRecipientStrings(getGroup().pendingMembers()), "as")),
new DbusProperty<>("RequestingMembers", new DbusProperty<>("RequestingMembers",
() -> new Variant<>(getRecipientStrings(getGroup().requestingMembers()), "as")), () -> new Variant<>(getRecipientStrings(getGroup().requestingMembers()), "as")),
new DbusProperty<>("Admins", new DbusProperty<>("Admins",
() -> new Variant<>(getRecipientStrings(getGroup().adminMembers()), "as")), () -> new Variant<>(getRecipientStrings(getGroup().members()
.stream()
.filter(GroupMember::isAdmin)
.map(GroupMember::recipientAddress)
.collect(Collectors.toSet())), "as")),
new DbusProperty<>("Banned", new DbusProperty<>("Banned",
() -> new Variant<>(getRecipientStrings(getGroup().bannedMembers()), "as")), () -> new Variant<>(getRecipientStrings(getGroup().bannedMembers()), "as")),
new DbusProperty<>("PermissionAddMember", new DbusProperty<>("PermissionAddMember",

View File

@ -2048,6 +2048,19 @@
"allDeclaredMethods": true, "allDeclaredMethods": true,
"allDeclaredConstructors": true "allDeclaredConstructors": true
}, },
{
"type": "org.asamk.signal.commands.ListGroupsCommand$JsonGroupMemberAddress",
"methods": [
{
"name": "number",
"parameterTypes": []
},
{
"name": "uuid",
"parameterTypes": []
}
]
},
{ {
"type": "org.asamk.signal.commands.ListIdentitiesCommand$JsonIdentity", "type": "org.asamk.signal.commands.ListIdentitiesCommand$JsonIdentity",
"allDeclaredFields": true, "allDeclaredFields": true,
@ -7793,6 +7806,18 @@
{ {
"type": "org.whispersystems.signalservice.internal.push.GroupMismatchedDevices[]" "type": "org.whispersystems.signalservice.internal.push.GroupMismatchedDevices[]"
}, },
{
"type": "org.whispersystems.signalservice.internal.push.GroupPatchResponse",
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.Integer",
"java.lang.String"
]
}
]
},
{ {
"type": "org.whispersystems.signalservice.internal.push.GroupStaleDevices", "type": "org.whispersystems.signalservice.internal.push.GroupStaleDevices",
"allDeclaredFields": true, "allDeclaredFields": true,