it.auties.whatsapp.socket.MessageHandler Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of whatsappweb4j Show documentation
Show all versions of whatsappweb4j Show documentation
Standalone fully-featured Whatsapp Web API for Java and Kotlin
package it.auties.whatsapp.socket;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import it.auties.whatsapp.util.Protobuf;
import it.auties.whatsapp.api.ClientType;
import it.auties.whatsapp.api.WebHistoryLength;
import it.auties.whatsapp.crypto.*;
import it.auties.whatsapp.model.action.ContactAction;
import it.auties.whatsapp.model.business.BusinessVerifiedNameCertificate;
import it.auties.whatsapp.model.business.BusinessVerifiedNameDetails;
import it.auties.whatsapp.model.chat.*;
import it.auties.whatsapp.model.chat.Chat.EndOfHistoryTransferType;
import it.auties.whatsapp.model.contact.Contact;
import it.auties.whatsapp.model.contact.ContactJid;
import it.auties.whatsapp.model.contact.ContactJid.Server;
import it.auties.whatsapp.model.contact.ContactJid.Type;
import it.auties.whatsapp.model.contact.ContactStatus;
import it.auties.whatsapp.model.info.MessageIndexInfo;
import it.auties.whatsapp.model.info.MessageInfo;
import it.auties.whatsapp.model.message.button.*;
import it.auties.whatsapp.model.message.model.*;
import it.auties.whatsapp.model.message.payment.PaymentOrderMessage;
import it.auties.whatsapp.model.message.server.DeviceSentMessage;
import it.auties.whatsapp.model.message.server.ProtocolMessage;
import it.auties.whatsapp.model.message.server.SenderKeyDistributionMessage;
import it.auties.whatsapp.model.message.standard.*;
import it.auties.whatsapp.model.request.Attributes;
import it.auties.whatsapp.model.request.MessageSendRequest;
import it.auties.whatsapp.model.request.Node;
import it.auties.whatsapp.model.setting.EphemeralSetting;
import it.auties.whatsapp.model.signal.keypair.SignalPreKeyPair;
import it.auties.whatsapp.model.signal.keypair.SignalSignedKeyPair;
import it.auties.whatsapp.model.signal.message.SignalDistributionMessage;
import it.auties.whatsapp.model.signal.message.SignalMessage;
import it.auties.whatsapp.model.signal.message.SignalPreKeyMessage;
import it.auties.whatsapp.model.signal.sender.SenderKeyName;
import it.auties.whatsapp.model.sync.HistorySync;
import it.auties.whatsapp.model.sync.PushName;
import it.auties.whatsapp.util.*;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static it.auties.whatsapp.api.ErrorHandler.Location.MESSAGE;
import static it.auties.whatsapp.api.ErrorHandler.Location.UNKNOWN;
import static it.auties.whatsapp.model.sync.HistorySync.Type.FULL;
import static it.auties.whatsapp.model.sync.HistorySync.Type.RECENT;
import static it.auties.whatsapp.util.Spec.Signal.*;
class MessageHandler {
private static final int MAX_ATTEMPTS = 3;
private static final int WEEKS_GROUP_METADATA_SYNC = 2;
private final SocketHandler socketHandler;
private final OrderedAsyncTaskRunner runner;
private final Map retries;
private final Cache groupsCache;
private final Cache> devicesCache;
private final Map> pastParticipantsQueue;
private final Set historyCache;
private final Logger logger;
private final DeferredTaskRunner deferredTaskRunner;
private final Set attributedGroups;
protected MessageHandler(SocketHandler socketHandler) {
this.socketHandler = socketHandler;
this.groupsCache = createCache(Duration.ofMinutes(5));
this.devicesCache = createCache(Duration.ofMinutes(5));
this.pastParticipantsQueue = new ConcurrentHashMap<>();
this.retries = new ConcurrentHashMap<>();
this.historyCache = ConcurrentHashMap.newKeySet();
this.attributedGroups = ConcurrentHashMap.newKeySet();
this.runner = new OrderedAsyncTaskRunner();
this.logger = System.getLogger("MessageHandler");
this.deferredTaskRunner = new DeferredTaskRunner();
}
private Cache createCache(Duration duration) {
return Caffeine.newBuilder().expireAfterWrite(duration).build();
}
protected synchronized CompletableFuture encode(MessageSendRequest request) {
return runner.runAsync(() -> encodeMessageNode(request)
.thenRunAsync(() -> attributeOutgoingMessage(request))
.exceptionallyAsync(throwable -> onEncodeError(request, throwable)));
}
private CompletableFuture encodeMessageNode(MessageSendRequest request) {
return request.peer() || isConversation(request.info()) ? encodeConversation(request) : encodeGroup(request);
}
private Void onEncodeError(MessageSendRequest request, Throwable throwable) {
request.info().status(MessageStatus.ERROR);
return socketHandler.handleFailure(MESSAGE, throwable);
}
private void attributeOutgoingMessage(MessageSendRequest request) {
if(request.peer()){
return;
}
saveMessage(request.info(), "unknown", false);
attributeMessageReceipt(request.info());
}
private CompletableFuture encodeGroup(MessageSendRequest request) {
var encodedMessage = BytesHelper.messageToBytes(request.info().message());
var senderName = new SenderKeyName(request.info().chatJid().toString(), socketHandler.store().jid().toSignalAddress());
var groupBuilder = new GroupBuilder(socketHandler.keys());
var signalMessage = groupBuilder.createOutgoing(senderName);
var groupCipher = new GroupCipher(senderName, socketHandler.keys());
var groupMessage = groupCipher.encrypt(encodedMessage);
var messageNode = createMessageNode(request, groupMessage);
if (request.hasSenderOverride()) {
return getGroupRetryDevices(request.overrideSender(), request.force()).thenComposeAsync(allDevices -> createGroupNodes(request, signalMessage, allDevices, request.force()))
.thenApplyAsync(preKeys -> createEncodedMessageNode(request, preKeys, messageNode))
.thenComposeAsync(socketHandler::send);
}
if (request.force()) {
return Optional.ofNullable(groupsCache.getIfPresent(request.info().chatJid()))
.map(CompletableFuture::completedFuture)
.orElseGet(() -> socketHandler.queryGroupMetadata(request.info().chatJid()))
.thenComposeAsync(devices -> getGroupDevices(devices, true))
.thenComposeAsync(allDevices -> createGroupNodes(request, signalMessage, allDevices, true))
.thenApplyAsync(preKeys -> createEncodedMessageNode(request, preKeys, messageNode))
.thenComposeAsync(socketHandler::send);
}
return socketHandler.queryGroupMetadata(request.info().chatJid())
.thenComposeAsync(devices -> getGroupDevices(devices, false))
.thenComposeAsync(allDevices -> createGroupNodes(request, signalMessage, allDevices, false))
.thenApplyAsync(preKeys -> createEncodedMessageNode(request, preKeys, messageNode))
.thenComposeAsync(socketHandler::send);
}
private CompletableFuture encodeConversation(MessageSendRequest request) {
var sender = socketHandler.store().jid();
if(sender == null){
return CompletableFuture.failedFuture(new IllegalStateException("Cannot create message: user is not signed in"));
}
var encodedMessage = BytesHelper.messageToBytes(request.info().message());
var knownDevices = getRecipients(request, sender);
var chatJid = request.info().chatJid();
if(request.peer()){
var peerNode = createMessageNode(request, chatJid, encodedMessage, true);
var encodedMessageNode = createEncodedMessageNode(request, List.of(peerNode), null);
return socketHandler.send(encodedMessageNode);
}
var deviceMessage = DeviceSentMessage.of(request.info().chatJid().toString(), request.info().message(), null);
var encodedDeviceMessage = BytesHelper.messageToBytes(deviceMessage);
return getDevices(knownDevices, true, request.force())
.thenComposeAsync(allDevices -> createConversationNodes(request, allDevices, encodedMessage, encodedDeviceMessage))
.thenApplyAsync(sessions -> createEncodedMessageNode(request, sessions, null))
.thenComposeAsync(socketHandler::send);
}
private List getRecipients(MessageSendRequest request, ContactJid sender) {
if(request.peer()){
return List.of(request.info().chatJid());
}
if (request.hasSenderOverride()) {
return List.of(request.overrideSender());
}
return List.of(sender.toWhatsappJid(), request.info().chatJid());
}
private boolean isConversation(MessageInfo info) {
return info.chatJid().hasServer(Server.WHATSAPP) || info.chatJid().hasServer(Server.USER);
}
private Node createEncodedMessageNode(MessageSendRequest request, List preKeys, Node descriptor) {
var body = new ArrayList();
if (!preKeys.isEmpty()) {
if (request.peer()) {
body.addAll(preKeys);
} else {
body.add(Node.ofChildren("participants", preKeys));
}
}
if (descriptor != null) {
body.add(descriptor);
}
if (!request.peer() && hasPreKeyMessage(preKeys)) {
var identity = Protobuf.writeMessage(socketHandler.keys().companionIdentity());
body.add(Node.of("device-identity", identity));
}
if(request.info().message().content() instanceof ButtonMessage buttonMessage) {
if(buttonMessage instanceof TemplateMessage templateMessage){
// var features = templateMessage.content()
// .hydratedButtons()
// .stream()
// .map(HydratedTemplateButton::button)
// .flatMap(Optional::stream)
// .map(entry -> Node.ofAttributes(
// "feature",
// Map.of(
// "name", entry.buttonType().name().toLowerCase(Locale.ROOT) + "_button"
// )
// ))
// .toList();
body.add(Node.ofChildren(
"hsm",
Map.of(
"category", "NON_TRANSACTIONAL",
"tag", templateMessage.id(),
"buttons", 1,
"v", 1
),
Node.ofChildren(
"capabilities" //,
// features
)
));
}else {
var type = getButtonType(request.info().message());
if (type != null) {
var args = getButtonArgs(buttonMessage);
body.add(Node.ofChildren(
"biz",
Node.ofAttributes(type, args)
));
}
}
}
var attributes = Attributes.ofNullable(request.additionalAttributes())
.put("id", request.info().id())
.put("to", request.info().chatJid())
.put("t", request.info().timestampSeconds())
.put("type", "text")
.put("category", "peer", request::peer)
.put("duration", "900", () -> request.info().message().type() == MessageType.LIVE_LOCATION)
.put("device_fanout", false, () -> request.info().message().type() == MessageType.BUTTONS)
.toMap();
return Node.ofChildren("message", attributes, body);
}
private boolean hasPreKeyMessage(List participants) {
return participants.stream()
.map(Node::children)
.flatMap(Collection::stream)
.map(node -> node.attributes().getOptionalString("type"))
.flatMap(Optional::stream)
.anyMatch("pkmsg"::equals);
}
private CompletableFuture> createConversationNodes(MessageSendRequest request, List contacts, byte[] message, byte[] deviceMessage) {
var partitioned = contacts.stream()
.collect(Collectors.partitioningBy(contact -> Objects.equals(contact.user(), socketHandler.store().jid().user())));
var companions = querySessions(partitioned.get(true), request.force())
.thenApplyAsync(ignored -> createMessageNodes(request, partitioned.get(true), deviceMessage));
var others = querySessions(partitioned.get(false), request.force())
.thenApplyAsync(ignored -> createMessageNodes(request, partitioned.get(false), message));
return companions.thenCombineAsync(others, (first, second) -> toSingleList(first, second));
}
private CompletableFuture> createGroupNodes(MessageSendRequest request, byte[] distributionMessage, List participants, boolean force) {
Validate.isTrue(request.info().chat().isGroup(), "Cannot send group message to non-group");
var missingParticipants = participants.stream()
.filter(participant -> !request.info().chat().participantsPreKeys().contains(participant))
.toList();
if (missingParticipants.isEmpty()) {
return CompletableFuture.completedFuture(List.of());
}
var whatsappMessage = new SenderKeyDistributionMessage(request.info().chatJid().toString(), distributionMessage);
var paddedMessage = BytesHelper.messageToBytes(whatsappMessage);
return querySessions(missingParticipants, force).thenApplyAsync(ignored -> createMessageNodes(request, missingParticipants, paddedMessage))
.thenApplyAsync(results -> savePreKeys(request.info().chat(), missingParticipants, results));
}
private List savePreKeys(Chat group, List missingParticipants, List results) {
group.participantsPreKeys().addAll(missingParticipants);
return results;
}
protected CompletableFuture querySessions(List contacts, boolean force) {
var missingSessions = contacts.stream()
.filter(contact -> force || !socketHandler.keys().hasSession(contact.toSignalAddress()))
.map(contact -> Node.ofAttributes("user", Map.of("jid", contact)))
.toList();
return missingSessions.isEmpty() ? CompletableFuture.completedFuture(null) : querySession(missingSessions);
}
private CompletableFuture querySession(List children){
return socketHandler.sendQuery("get", "encrypt", Node.ofChildren("key", children))
.thenAcceptAsync(this::parseSessions);
}
private List createMessageNodes(MessageSendRequest request, List contacts, byte[] message) {
return contacts.stream()
.map(contact -> createMessageNode(request, contact, message, false))
.toList();
}
private Node createMessageNode(MessageSendRequest request, ContactJid contact, byte[] message, boolean peer) {
var cipher = new SessionCipher(contact.toSignalAddress(), socketHandler.keys());
var encrypted = cipher.encrypt(message);
var messageNode = createMessageNode(request, encrypted);
return peer ? messageNode : Node.ofChildren("to", Map.of("jid", contact), messageNode);
}
private CompletableFuture> getGroupRetryDevices(ContactJid contactJid, boolean force) {
return getDevices(List.of(contactJid), false, force);
}
private CompletableFuture> getGroupDevices(GroupMetadata metadata, boolean force) {
groupsCache.put(metadata.jid(), metadata);
return getDevices(metadata.participantsJids(), false, force);
}
protected CompletableFuture> getDevices(List contacts, boolean excludeSelf, boolean force) {
if (force) {
return queryDevices(contacts, excludeSelf)
.thenApplyAsync(missingDevices -> excludeSelf ? toSingleList(contacts, missingDevices) : missingDevices);
}
var partitioned = contacts.stream()
.collect(Collectors.partitioningBy(contact -> devicesCache.asMap().containsKey(contact.user()), Collectors.toUnmodifiableList()));
var cached = partitioned.get(true)
.stream()
.map(ContactJid::user)
.map(devicesCache::getIfPresent)
.filter(Objects::nonNull)
.flatMap(Collection::stream)
.toList();
var missing = partitioned.get(false);
if (missing.isEmpty()) {
return CompletableFuture.completedFuture(excludeSelf ? toSingleList(contacts, cached) : cached);
}
return queryDevices(missing, excludeSelf)
.thenApplyAsync(missingDevices -> excludeSelf ? toSingleList(contacts, cached, missingDevices) : toSingleList(cached, missingDevices));
}
private CompletableFuture> queryDevices(List contacts, boolean excludeSelf) {
var contactNodes = contacts.stream()
.map(contact -> Node.ofAttributes("user", Map.of("jid", contact)))
.toList();
var body = Node.ofChildren("usync",
Map.of("sid", socketHandler.store().nextTag(), "mode", "query", "last", "true", "index", "0", "context", "message"),
Node.ofChildren("query", Node.ofAttributes("devices", Map.of("version", "2"))),
Node.ofChildren("list", contactNodes));
return socketHandler.sendQuery("get", "usync", body)
.thenApplyAsync(result -> parseDevices(result, excludeSelf));
}
private List parseDevices(Node node, boolean excludeSelf) {
var results = node.children()
.stream()
.map(child -> child.findNode("list"))
.flatMap(Optional::stream)
.map(Node::children)
.flatMap(Collection::stream)
.map(entry -> parseDevice(entry, excludeSelf))
.flatMap(Collection::stream)
.toList();
devicesCache.putAll(results.stream().collect(Collectors.groupingBy(ContactJid::user)));
return results;
}
private List parseDevice(Node wrapper, boolean excludeSelf) {
var jid = wrapper.attributes()
.getJid("jid")
.orElseThrow(() -> new NoSuchElementException("Missing jid for sync device"));
return wrapper.findNode("devices")
.orElseThrow(() -> new NoSuchElementException("Missing devices"))
.findNode("device-list")
.orElseThrow(() -> new NoSuchElementException("Missing device list"))
.children()
.stream()
.map(child -> parseDeviceId(child, jid, excludeSelf))
.flatMap(Optional::stream)
.map(id -> ContactJid.ofDevice(jid.user(), id))
.toList();
}
private Optional parseDeviceId(Node child, ContactJid jid, boolean excludeSelf) {
var deviceId = child.attributes().getInt("id");
return child.description().equals("device") && (!excludeSelf || deviceId != 0) && (!jid.user()
.equals(socketHandler.store().jid().user()) || socketHandler.store()
.jid()
.device() != deviceId) && (deviceId == 0 || child.attributes()
.hasKey("key-index")) ? Optional.of(deviceId) : Optional.empty();
}
protected void parseSessions(Node node) {
node.findNode("list")
.orElseThrow(() -> new NoSuchElementException("Missing list: %s".formatted(node)))
.findNodes("user")
.forEach(this::parseSession);
}
private void parseSession(Node node) {
Validate.isTrue(!node.hasNode("error"), "Erroneous session node", SecurityException.class);
var jid = node.attributes()
.getJid("jid")
.orElseThrow(() -> new NoSuchElementException("Missing jid for session"));
var registrationId = node.findNode("registration")
.map(id -> BytesHelper.bytesToInt(id.contentAsBytes().orElseThrow(), 4))
.orElseThrow(() -> new NoSuchElementException("Missing id"));
var identity = node.findNode("identity")
.flatMap(Node::contentAsBytes)
.map(KeyHelper::withHeader)
.orElseThrow(() -> new NoSuchElementException("Missing identity"));
var signedKey = node.findNode("skey")
.flatMap(SignalSignedKeyPair::of)
.orElseThrow(() -> new NoSuchElementException("Missing signed key"));
var key = node.findNode("key")
.flatMap(SignalSignedKeyPair::of)
.orElse(null);
var builder = new SessionBuilder(jid.toSignalAddress(), socketHandler.keys());
builder.createOutgoing(registrationId, identity, signedKey, key);
}
public synchronized void decode(Node node) {
try {
var businessName = getBusinessName(node);
var encrypted = node.findNodes("enc");
if (node.hasNode("unavailable") && !node.hasNode("enc")) {
decodeMessage(node, null, businessName);
return;
}
encrypted.forEach(message -> decodeMessage(node, message, businessName));
} catch (Throwable throwable) {
socketHandler.handleFailure(MESSAGE, throwable);
}
}
private String getBusinessName(Node node) {
return node.attributes()
.getOptionalString("verified_name")
.or(() -> getBusinessNameFromNode(node))
.orElse(null);
}
private static Optional getBusinessNameFromNode(Node node) {
return node.findNode("verified_name")
.flatMap(Node::contentAsBytes)
.map(bytes -> Protobuf.readMessage(bytes, BusinessVerifiedNameCertificate.class))
.map(certificate -> Protobuf.readMessage(certificate.details(), BusinessVerifiedNameDetails.class))
.map(BusinessVerifiedNameDetails::name);
}
private Node createMessageNode(MessageSendRequest request, CipheredMessageResult groupMessage) {
var mediaType = getMediaType(request.info().message());
var attributes = Attributes.of()
.put("v", "2")
.put("type", groupMessage.type())
.put("mediatype", mediaType, Objects::nonNull)
.toMap();
return Node.of("enc", attributes, groupMessage.message());
}
private String getButtonType(MessageContainer container){
return switch (container.type()){
case BUTTONS -> "buttons";
case INTERACTIVE_RESPONSE -> "interactive_response";
case LIST -> "list";
case LIST_RESPONSE -> "list_response";
default -> null;
};
}
private Map getButtonArgs(ButtonMessage buttonMessage) {
return switch (buttonMessage) {
case ListMessage listMessage -> Map.of(
"v", 2,
"type", listMessage.listType().name().toLowerCase()
);
default -> Map.of();
};
}
private String getMediaType(MessageContainer container){
return switch (container.content()){
case ImageMessage ignored -> "image";
case VideoMessage videoMessage -> videoMessage.gifPlayback() ? "gif" : "video";
case AudioMessage audioMessage -> audioMessage.voiceMessage() ? "ptt" : "audio";
case ContactMessage ignored -> "vcard";
case DocumentMessage ignored -> "document";
case ContactsArrayMessage ignored -> "contact_array";
case LiveLocationMessage ignored -> "livelocation";
case StickerMessage ignored -> "sticker";
case ListMessage ignored -> "list";
case ListResponseMessage ignored -> "list_response";
case ButtonsResponseMessage ignored -> "buttons_response";
case PaymentOrderMessage ignored -> "order";
case ProductMessage ignored -> "product";
case NativeFlowResponseMessage ignored -> "native_flow_response";
case ButtonsMessage buttonsMessage -> buttonsMessage.headerType().hasMedia() ? buttonsMessage.headerType().name().toLowerCase() : null;
default -> null;
};
}
private void decodeMessage(Node infoNode, Node messageNode, String businessName) {
try {
var offline = infoNode.attributes().hasKey("offline");
var pushName = infoNode.attributes().getNullableString("notify");
var timestamp = infoNode.attributes().getLong("t");
var id = infoNode.attributes().getRequiredString("id");
var from = infoNode.attributes()
.getJid("from")
.orElseThrow(() -> new NoSuchElementException("Missing from"));
var recipient = infoNode.attributes().getJid("recipient").orElse(from);
var participant = infoNode.attributes().getJid("participant").orElse(null);
var messageBuilder = MessageInfo.builder();
var keyBuilder = MessageKey.builder();
var userCompanionJid = socketHandler.store().jid();
if(userCompanionJid == null){
return; // This means that the session got disconnected while processing
}
var receiver = userCompanionJid.toWhatsappJid();
if (from.hasServer(ContactJid.Server.WHATSAPP) || from.hasServer(ContactJid.Server.USER)) {
keyBuilder.chatJid(recipient);
keyBuilder.senderJid(from);
keyBuilder.fromMe(Objects.equals(from, receiver));
messageBuilder.senderJid(from);
} else {
keyBuilder.chatJid(from);
keyBuilder.senderJid(Objects.requireNonNull(participant, "Missing participant in group message"));
keyBuilder.fromMe(Objects.equals(participant.toWhatsappJid(), receiver));
messageBuilder.senderJid(Objects.requireNonNull(participant, "Missing participant in group message"));
}
var key = keyBuilder.id(id).build();
if(isSelfMessage(key)){
socketHandler.sendReceipt(key.chatJid(), key.senderJid().orElse(key.chatJid()), List.of(key.id()), null);
return;
}
if (messageNode == null) {
if(sendRetryReceipt(timestamp, id, from, recipient, participant, null, null, null)){
return;
}
socketHandler.sendReceipt(key.chatJid(), key.senderJid().orElse(key.chatJid()), List.of(key.id()), null);
return;
}
var type = messageNode.attributes().getRequiredString("type");
var encodedMessage = messageNode.contentAsBytes().orElse(null);
var decodedMessage = decodeMessageBytes(type, encodedMessage, from, participant);
if (decodedMessage.hasError()) {
if(sendRetryReceipt(timestamp, id, from, recipient, participant, type, encodedMessage, decodedMessage)){
return;
}
socketHandler.sendReceipt(key.chatJid(), key.senderJid().orElse(key.chatJid()), List.of(key.id()), null);
return;
}
var messageContainer = BytesHelper.bytesToMessage(decodedMessage.message()).unbox();
var info = messageBuilder.key(key)
.broadcast(key.chatJid().hasServer(Server.BROADCAST))
.pushName(pushName)
.status(MessageStatus.DELIVERED)
.businessVerifiedName(businessName)
.timestampSeconds(timestamp)
.message(messageContainer)
.build();
attributeMessageReceipt(info);
socketHandler.store().attribute(info);
var category = infoNode.attributes().getString("category");
saveMessage(info, category, offline);
socketHandler.sendReceipt(info.chatJid(), info.senderJid(), List.of(info.key().id()), null);
socketHandler.onReply(info);
} catch (Throwable throwable) {
socketHandler.handleFailure(MESSAGE, throwable);
}
}
private boolean isSelfMessage(MessageKey key) {
return socketHandler.store().clientType() == ClientType.APP_CLIENT
&& key.fromMe()
&& key.senderJid().isPresent()
&& !key.senderJid().get().hasAgent();
}
private boolean sendRetryReceipt(long timestamp, String id, ContactJid from, ContactJid recipient, ContactJid participant, String type, byte[] encodedMessage, MessageDecodeResult decodedMessage) {
if(encodedMessage != null) {
logger.log(Level.WARNING, "Cannot decode message(id: %s, from: %s): %s".formatted(id, from, decodedMessage == null ? "unknown error" : decodedMessage.error().getMessage()));
}
if(socketHandler.store().clientType() == ClientType.APP_CLIENT){
return false;
}
var attempts = retries.getOrDefault(id, 0);
if (attempts >= MAX_ATTEMPTS) {
var cause = decodedMessage != null ? decodedMessage.error() : new RuntimeException("This message is not available");
socketHandler.handleFailure(MESSAGE, new RuntimeException("Cannot decrypt message with type %s inside %s from %s".formatted(Objects.requireNonNullElse(type, "unknown"), from, Objects.requireNonNullElse(participant, from)), cause));
return false;
}
var retryAttributes = Attributes.of()
.put("id", id)
.put("type", "retry")
.put("to", from)
.put("recipient", recipient, () -> !Objects.equals(recipient, from))
.put("participant", participant, Objects::nonNull)
.toMap();
var retryNode = Node.ofChildren("receipt", retryAttributes,
Node.ofAttributes("retry", Map.of("count", attempts, "id", id, "t", timestamp, "v", "1")),
Node.of("registration", socketHandler.keys().encodedRegistrationId()),
attempts > 1 || encodedMessage == null ? createPreKeyNode() : null);
socketHandler.sendWithNoResponse(retryNode);
retries.put(id, attempts + 1);
return true;
}
private MessageDecodeResult decodeMessageBytes(String type, byte[] encodedMessage, ContactJid from, ContactJid participant) {
try {
if (encodedMessage == null) {
return new MessageDecodeResult(null, new IllegalArgumentException("Missing encoded message"));
}
var result = switch (type) {
case SKMSG -> {
Objects.requireNonNull(participant, "Cannot decipher skmsg without participant");
var senderName = new SenderKeyName(from.toString(), participant.toSignalAddress());
var signalGroup = new GroupCipher(senderName, socketHandler.keys());
yield signalGroup.decrypt(encodedMessage);
}
case PKMSG -> {
var user = from.hasServer(ContactJid.Server.WHATSAPP) ? from : participant;
Objects.requireNonNull(user, "Cannot decipher pkmsg without user");
var session = new SessionCipher(user.toSignalAddress(), socketHandler.keys());
var preKey = SignalPreKeyMessage.ofSerialized(encodedMessage);
yield session.decrypt(preKey);
}
case MSG -> {
var user = from.hasServer(ContactJid.Server.WHATSAPP) ? from : participant;
Objects.requireNonNull(user, "Cannot decipher msg without user");
var session = new SessionCipher(user.toSignalAddress(), socketHandler.keys());
var signalMessage = SignalMessage.ofSerialized(encodedMessage);
yield session.decrypt(signalMessage);
}
default -> throw new IllegalArgumentException("Unsupported encoded message type: %s".formatted(type));
};
return new MessageDecodeResult(result, null);
} catch (Throwable throwable) {
return new MessageDecodeResult(null, throwable);
}
}
private void attributeMessageReceipt(MessageInfo info) {
var self = socketHandler.store().jid().toWhatsappJid();
if (!info.fromMe() || !info.chatJid().equals(self)) {
return;
}
info.receipt().readTimestampSeconds(info.timestampSeconds());
info.receipt().deliveredJids().add(self);
info.receipt().readJids().add(self);
info.status(MessageStatus.READ);
}
private void saveMessage(MessageInfo info, String category, boolean offline) {
if(info.message().content() instanceof SenderKeyDistributionMessage distributionMessage) {
handleDistributionMessage(distributionMessage, info.senderJid());
}
if (info.chatJid().type() == Type.STATUS) {
socketHandler.store().addStatus(info);
socketHandler.onNewStatus(info);
return;
}
if (info.message().hasCategory(MessageCategory.SERVER)) {
if (info.message().content() instanceof ProtocolMessage protocolMessage) {
onProtocolMessage(info, protocolMessage, Objects.equals(category, "peer"));
}
return;
}
var result = info.chat().addNewMessage(info);
if (!result || info.timestampSeconds() <= socketHandler.store().initializationTimeStamp()) {
return;
}
if (info.chat().archived() && socketHandler.store().unarchiveChats()) {
info.chat().archived(false);
}
info.sender()
.filter(this::isTyping)
.ifPresent(sender -> socketHandler.onUpdateChatPresence(ContactStatus.AVAILABLE, sender, info.chat()));
if (!info.ignore() && !info.fromMe()) {
info.chat().unreadMessagesCount(info.chat().unreadMessagesCount() + 1);
}
socketHandler.onNewMessage(info, offline);
}
private void handleDistributionMessage(SenderKeyDistributionMessage distributionMessage, ContactJid from) {
var groupName = new SenderKeyName(distributionMessage.groupId(), from.toSignalAddress());
var builder = new GroupBuilder(socketHandler.keys());
var message = SignalDistributionMessage.ofSerialized(distributionMessage.data());
builder.createIncoming(groupName, message);
}
private Node createPreKeyNode() {
var preKey = SignalPreKeyPair.random(socketHandler.keys().lastPreKeyId() + 1);
var identity = Protobuf.writeMessage(socketHandler.keys().companionIdentity());
return Node.ofChildren("keys",
Node.of("type", Spec.Signal.KEY_BUNDLE_TYPE),
Node.of("identity", socketHandler.keys().identityKeyPair().publicKey()),
preKey.toNode(),
socketHandler.keys().signedKeyPair().toNode(),
Node.of("device-identity", identity));
}
private void onProtocolMessage(MessageInfo info, ProtocolMessage protocolMessage, boolean peer) {
handleProtocolMessage(info, protocolMessage);
if (!peer) {
return;
}
socketHandler.sendSyncReceipt(info, "peer_msg");
}
private void handleProtocolMessage(MessageInfo info, ProtocolMessage protocolMessage) {
switch (protocolMessage.protocolType()) {
case HISTORY_SYNC_NOTIFICATION -> onHistorySyncNotification(info, protocolMessage);
case APP_STATE_SYNC_KEY_SHARE -> onAppStateSyncKeyShare(protocolMessage);
case REVOKE -> onMessageRevoked(info, protocolMessage);
case EPHEMERAL_SETTING -> onEphemeralSettings(info, protocolMessage);
}
}
private void onEphemeralSettings(MessageInfo info, ProtocolMessage protocolMessage) {
info.chat()
.ephemeralMessagesToggleTime(info.timestampSeconds())
.ephemeralMessageDuration(ChatEphemeralTimer.of(protocolMessage.ephemeralExpiration()));
var setting = new EphemeralSetting((int) protocolMessage.ephemeralExpiration(), info.timestampSeconds());
socketHandler.onSetting(setting);
}
private void onMessageRevoked(MessageInfo info, ProtocolMessage protocolMessage) {
socketHandler.store()
.findMessageById(info.chat(), protocolMessage.key().id())
.ifPresent(message -> onMessageDeleted(info, message));
}
private void onAppStateSyncKeyShare(ProtocolMessage protocolMessage) {
socketHandler.keys().addAppKeys(protocolMessage.appStateSyncKeyShare().keys());
if (socketHandler.store().initialSync()) {
return;
}
socketHandler.pullInitialPatches()
.exceptionallyAsync(throwable -> socketHandler
.handleFailure(UNKNOWN, throwable));
}
private void onHistorySyncNotification(MessageInfo info, ProtocolMessage protocolMessage) {
downloadHistorySync(protocolMessage)
.thenAcceptAsync(history -> onHistoryNotification(info, history))
.exceptionallyAsync(throwable -> socketHandler.handleFailure(MESSAGE, throwable));
}
private boolean isTyping(Contact sender) {
return sender.lastKnownPresence() == ContactStatus.COMPOSING || sender.lastKnownPresence() == ContactStatus.RECORDING;
}
private CompletableFuture downloadHistorySync(ProtocolMessage protocolMessage) {
return Medias.download(protocolMessage.historySyncNotification())
.thenApplyAsync(entry -> entry.orElseThrow(() -> new NoSuchElementException("Cannot download history sync")))
.thenApplyAsync(result -> Protobuf.readMessage(BytesHelper.decompress(result), HistorySync.class));
}
private void onHistoryNotification(MessageInfo info, HistorySync history) {
handleHistorySync(history);
if (history.progress() != null) {
if(isSyncComplete(history)) {
handleChatsSync(history, true);
}
socketHandler.onHistorySyncProgress(history.progress(), history.syncType() == RECENT);
}
socketHandler.sendSyncReceipt(info, "hist_sync");
}
private boolean isSyncComplete(HistorySync history) {
return history.progress() == 100
&& socketHandler.store().historyLength() == WebHistoryLength.STANDARD ? history.syncType() == RECENT : history.syncType() == FULL;
}
private void onMessageDeleted(MessageInfo info, MessageInfo message) {
info.chat().removeMessage(message);
message.revokeTimestampSeconds(Clock.nowSeconds());
socketHandler.onMessageDeleted(message, true);
}
private void handleHistorySync(HistorySync history) {
switch (history.syncType()) {
case INITIAL_STATUS_V3 -> handleInitialStatus(history);
case PUSH_NAME -> handlePushNames(history);
case INITIAL_BOOTSTRAP -> handleInitialBootstrap(history);
case RECENT, FULL -> {
deferredTaskRunner.execute();
handleChatsSync(history, false);
}
case NON_BLOCKING_DATA -> handleNonBlockingData(history);
}
}
private void handleInitialStatus(HistorySync history) {
var store = socketHandler.store();
for (var messageInfo : history.statusV3Messages()) {
store.addStatus(messageInfo);
}
socketHandler.onStatus();
}
private void handlePushNames(HistorySync history) {
for (var pushName : history.pushNames()) {
handNewPushName(pushName);
}
socketHandler.onContacts();
}
private void handNewPushName(PushName pushName) {
var jid = ContactJid.of(pushName.id());
var contact = socketHandler.store()
.findContactByJid(jid)
.orElseGet(() -> createNewContact(jid));
contact.chosenName(pushName.name());
var action = ContactAction.of(pushName.name(), null, null);
socketHandler.onAction(action, MessageIndexInfo.of("contact", jid, null, true));
}
private Contact createNewContact(ContactJid jid) {
var contact = socketHandler.store().addContact(jid);
socketHandler.onNewContact(contact);
return contact;
}
private void handleInitialBootstrap(HistorySync history) {
if(socketHandler.store().historyLength() != WebHistoryLength.ZERO){
historyCache.addAll(history.conversations());
}
handleConversations(history);
socketHandler.onChats();
}
private void handleChatsSync(HistorySync history, boolean forceDone) {
if(socketHandler.store().historyLength() == WebHistoryLength.ZERO){
return;
}
handleConversations(history);
for (var cached : historyCache) {
var chat = socketHandler.store()
.findChatByJid(cached.jid())
.orElse(cached);
var done = forceDone || !history.conversations().contains(cached);
if(done){
chat.endOfHistoryTransferType(EndOfHistoryTransferType.COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY);
}
socketHandler.onChatRecentMessages(chat, done);
}
historyCache.removeIf(entry -> !history.conversations().contains(entry));
}
private void handleConversations(HistorySync history) {
var store = socketHandler.store();
for (var chat : history.conversations()) {
var pastParticipants = pastParticipantsQueue.remove(chat.jid());
if (pastParticipants != null) {
chat.addPastParticipants(pastParticipants);
}
if(shouldSyncGroupMetadata(chat)){
attributedGroups.add(chat.jid());
deferredTaskRunner.schedule(() -> socketHandler.queryGroupMetadata(chat));
}
store.addChat(chat);
}
}
private boolean shouldSyncGroupMetadata(Chat chat) {
return chat.isGroup()
&& !attributedGroups.contains(chat.jid())
&& chat.timestamp().until(ZonedDateTime.now(), ChronoUnit.WEEKS) < WEEKS_GROUP_METADATA_SYNC;
}
private void handleNonBlockingData(HistorySync history) {
for (var pastParticipants : history.pastParticipants()) {
handlePastParticipants(pastParticipants);
}
}
private void handlePastParticipants(PastParticipants pastParticipants) {
socketHandler.store()
.findChatByJid(pastParticipants.groupJid())
.ifPresentOrElse(chat -> chat.addPastParticipants(pastParticipants.pastParticipants()),
() -> pastParticipantsQueue.put(pastParticipants.groupJid(), pastParticipants.pastParticipants()));
}
@SafeVarargs
private List toSingleList(List... all) {
return Stream.of(all).filter(Objects::nonNull).flatMap(Collection::stream).toList();
}
protected void dispose() {
retries.clear();
groupsCache.invalidateAll();
devicesCache.invalidateAll();
historyCache.clear();
runner.cancel();
}
private record MessageDecodeResult(byte[] message, Throwable error) {
public boolean hasError() {
return error != null;
}
}
}