From 9e9c2334686631c30f615bd86ab3b7c46a27a0f8 Mon Sep 17 00:00:00 2001 From: Martin Fidczuk Date: Mon, 1 Aug 2022 17:06:14 +0100 Subject: [PATCH] Add partial support for XEP-0249 Direct MUC Invitations. Exposes a method for a MUC to invite a user to the room, and adds a listener to the MultiUserChat listener to inform users of direct invitations they have received. Fixes SMACK-932. --- .../smackx/muc/MultiUserChat.java | 46 +++ .../smackx/muc/MultiUserChatManager.java | 34 ++ .../muc/packet/GroupChatInvitation.java | 71 ++++ .../smackx/muc/MultiUserChatTest.java | 324 ++++++++++++++++++ .../org/jivesoftware/smackx/package-info.java | 6 + 5 files changed, 481 insertions(+) create mode 100644 smack-extensions/src/test/java/org/jivesoftware/smackx/muc/MultiUserChatTest.java diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/MultiUserChat.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/MultiUserChat.java index a55e850b2..b7fd3ad97 100644 --- a/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/MultiUserChat.java +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/MultiUserChat.java @@ -74,6 +74,7 @@ import org.jivesoftware.smackx.muc.MultiUserChatException.MucNotJoinedException; import org.jivesoftware.smackx.muc.MultiUserChatException.NotAMucServiceException; import org.jivesoftware.smackx.muc.filter.MUCUserStatusCodeFilter; import org.jivesoftware.smackx.muc.packet.Destroy; +import org.jivesoftware.smackx.muc.packet.GroupChatInvitation; import org.jivesoftware.smackx.muc.packet.MUCAdmin; import org.jivesoftware.smackx.muc.packet.MUCInitialPresence; import org.jivesoftware.smackx.muc.packet.MUCItem; @@ -1063,6 +1064,51 @@ public class MultiUserChat { connection.sendStanza(message); } + /** + * Invites another user to the room in which one is an occupant. In contrast + * to the method "invite", the invitation is sent directly to the user rather + * than via the chat room. This is useful when the user being invited is + * offline, as otherwise the invitation would be dropped. + * + * @param address the user to send the invitation to + * @throws NotConnectedException if the XMPP connection is not connected. + * @throws InterruptedException if the calling thread was interrupted. + */ + public void inviteDirectly(EntityBareJid address) throws NotConnectedException, InterruptedException { + inviteDirectly(address, null, null, false, null); + } + + /** + * Invites another user to the room in which one is an occupant. In contrast + * to the method "invite", the invitation is sent directly to the user rather + * than via the chat room. This is useful when the user being invited is + * offline, as otherwise the invitation would be dropped. + * + * @param address the user to send the invitation to + * @param reason the purpose for the invitation + * @param password specifies a password needed for entry + * @param continueAsOneToOneChat specifies if the groupchat room continues a one-to-one chat having the designated thread + * @param thread the thread to continue + * @throws NotConnectedException if the XMPP connection is not connected. + * @throws InterruptedException if the calling thread was interrupted. + */ + public void inviteDirectly(EntityBareJid address, String reason, String password, boolean continueAsOneToOneChat, String thread) + throws NotConnectedException, InterruptedException { + // Add the extension for direct invitation + GroupChatInvitation invitationExt = new GroupChatInvitation(room, + reason, + password, + continueAsOneToOneChat, + thread); + + Message message = connection.getStanzaFactory().buildMessageStanza() + .to(address) + .addExtension(invitationExt) + .build(); + + connection.sendStanza(message); + } + /** * Adds a listener to invitation rejections notifications. The listener will be fired anytime * an invitation is declined. diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/MultiUserChatManager.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/MultiUserChatManager.java index 55a791faf..00b088095 100644 --- a/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/MultiUserChatManager.java +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/MultiUserChatManager.java @@ -40,6 +40,7 @@ import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.XMPPConnectionRegistry; import org.jivesoftware.smack.XMPPException.XMPPErrorException; import org.jivesoftware.smack.filter.AndFilter; +import org.jivesoftware.smack.filter.ExtensionElementFilter; import org.jivesoftware.smack.filter.MessageTypeFilter; import org.jivesoftware.smack.filter.NotFilter; import org.jivesoftware.smack.filter.StanzaExtensionFilter; @@ -57,6 +58,7 @@ import org.jivesoftware.smackx.disco.packet.DiscoverInfo; import org.jivesoftware.smackx.disco.packet.DiscoverItems; import org.jivesoftware.smackx.muc.MultiUserChatException.MucNotJoinedException; import org.jivesoftware.smackx.muc.MultiUserChatException.NotAMucServiceException; +import org.jivesoftware.smackx.muc.packet.GroupChatInvitation; import org.jivesoftware.smackx.muc.packet.MUCInitialPresence; import org.jivesoftware.smackx.muc.packet.MUCUser; @@ -139,6 +141,11 @@ public final class MultiUserChatManager extends Manager { private static final StanzaFilter INVITATION_FILTER = new AndFilter(StanzaTypeFilter.MESSAGE, new StanzaExtensionFilter(new MUCUser()), new NotFilter(MessageTypeFilter.ERROR)); + private static final StanzaFilter DIRECT_INVITATION_FILTER = + new AndFilter(StanzaTypeFilter.MESSAGE, + new ExtensionElementFilter(GroupChatInvitation.class), + new NotFilter(MessageTypeFilter.ERROR)); + private static final ExpirationCache KNOWN_MUC_SERVICES = new ExpirationCache<>( 100, 1000 * 60 * 60 * 24); @@ -199,6 +206,33 @@ public final class MultiUserChatManager extends Manager { }; connection.addAsyncStanzaListener(invitationPacketListener, INVITATION_FILTER); + // Listens for all messages that include an XEP-0249 GroupChatInvitation extension and fire the invitation + // listeners + StanzaListener directInvitationStanzaListener = new StanzaListener() { + @Override + public void processStanza(Stanza stanza) { + final Message message = (Message) stanza; + GroupChatInvitation invite = + stanza.getExtension(GroupChatInvitation.class); + + // Fire event for invitation listeners + final MultiUserChat muc = getMultiUserChat(invite.getRoomAddress()); + final XMPPConnection connection = connection(); + final EntityJid from = message.getFrom().asEntityJidIfPossible(); + if (from == null) { + LOGGER.warning("Group Chat Invitation from non entity JID in '" + message + "'"); + return; + } + final String reason = invite.getReason(); + final String password = invite.getPassword(); + final MUCUser.Invite mucInvite = new MUCUser.Invite(reason, from, connection.getUser().asEntityBareJid()); + for (final InvitationListener listener : invitationsListeners) { + listener.invitationReceived(connection, muc, from, reason, password, message, mucInvite); + } + } + }; + connection.addAsyncStanzaListener(directInvitationStanzaListener, DIRECT_INVITATION_FILTER); + connection.addConnectionListener(new ConnectionListener() { @Override public void authenticated(XMPPConnection connection, boolean resumed) { diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/packet/GroupChatInvitation.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/packet/GroupChatInvitation.java index a2b5f2ea7..dc6660ae9 100644 --- a/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/packet/GroupChatInvitation.java +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/muc/packet/GroupChatInvitation.java @@ -69,6 +69,10 @@ public class GroupChatInvitation implements ExtensionElement { public static final QName QNAME = new QName(NAMESPACE, ELEMENT); private final EntityBareJid roomAddress; + private final String reason; + private final String password; + private final String thread; + private final boolean continueAsOneToOneChat; /** * Creates a new group chat invitation to the specified room address. @@ -79,7 +83,67 @@ public class GroupChatInvitation implements ExtensionElement { * @param roomAddress the address of the group chat room. */ public GroupChatInvitation(EntityBareJid roomAddress) { + this(roomAddress, null, null, false, null); + } + + /** + * Creates a new group chat invitation to the specified room address. + * GroupChat room addresses are in the form room@service, + * where service is the name of group chat server, such as + * chat.example.com. + * + * @param roomAddress the address of the group chat room. + * @param reason the purpose for the invitation + * @param password specifies a password needed for entry + * @param continueAsOneToOneChat specifies if the groupchat room continues a one-to-one chat having the designated thread + * @param thread the thread to continue + */ + public GroupChatInvitation(EntityBareJid roomAddress, + String reason, + String password, + boolean continueAsOneToOneChat, + String thread) { this.roomAddress = Objects.requireNonNull(roomAddress); + this.reason = reason; + this.password = password; + this.continueAsOneToOneChat = continueAsOneToOneChat; + this.thread = thread; + } + + /** + * Returns the purpose for the invitation. + * + * @return the address of the group chat room. + */ + public String getReason() { + return reason; + } + + /** + * Returns the password needed for entry. + * + * @return the password needed for entry + */ + public String getPassword() { + return password; + } + + /** + * Returns the thread to continue. + * + * @return the thread to continue. + */ + public String getThread() { + return thread; + } + + /** + * Returns whether the groupchat room continues a one-to-one chat. + * + * @return whether the groupchat room continues a one-to-one chat. + */ + public boolean continueAsOneToOneChat() { + return continueAsOneToOneChat; } /** @@ -107,6 +171,13 @@ public class GroupChatInvitation implements ExtensionElement { public XmlStringBuilder toXML(org.jivesoftware.smack.packet.XmlEnvironment enclosingNamespace) { XmlStringBuilder xml = new XmlStringBuilder(this); xml.attribute("jid", getRoomAddress()); + xml.optAttribute("reason", getReason()); + xml.optAttribute("password", getPassword()); + xml.optAttribute("thread", getThread()); + + if (continueAsOneToOneChat()) + xml.optBooleanAttribute("continue", true); + xml.closeEmptyElement(); return xml; } diff --git a/smack-extensions/src/test/java/org/jivesoftware/smackx/muc/MultiUserChatTest.java b/smack-extensions/src/test/java/org/jivesoftware/smackx/muc/MultiUserChatTest.java new file mode 100644 index 000000000..6ffddb7a5 --- /dev/null +++ b/smack-extensions/src/test/java/org/jivesoftware/smackx/muc/MultiUserChatTest.java @@ -0,0 +1,324 @@ +/** + * + * Copyright 2021-2022 Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.muc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.jivesoftware.smack.DummyConnection; +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.test.util.SmackTestSuite; +import org.jivesoftware.smack.test.util.WaitForPacketListener; +import org.jivesoftware.smackx.muc.packet.GroupChatInvitation; +import org.jivesoftware.smackx.muc.packet.MUCUser; +import org.jivesoftware.smackx.muc.packet.MUCUser.Invite; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.jxmpp.jid.EntityBareJid; +import org.jxmpp.jid.EntityFullJid; +import org.jxmpp.jid.EntityJid; +import org.jxmpp.jid.impl.JidCreate; +import org.jxmpp.stringprep.XmppStringprepException; + +/** + * A test for for following features: + *
  • Adds support for Direct MUC invitations (see XEP-0249), which allows offline users to be invited to group chats.
  • + * + */ +public class MultiUserChatTest extends SmackTestSuite { + private static final int RESPONSE_TIMEOUT_IN_MILLIS = 10000; + + private DummyConnection connection; + private MultiUserChatManager multiUserChatManager; + + @BeforeEach + public void setUp() throws Exception { + connection = new DummyConnection(); + connection.connect(); + connection.login(); + + multiUserChatManager = MultiUserChatManager.getInstanceFor(connection); + } + + @AfterEach + public void tearDown() { + if (connection != null) { + connection.disconnect(); + connection = null; + } + } + + @Test + public void testInviteDirectly() throws Throwable { + EntityBareJid roomJid = JidCreate.entityBareFrom("room@example.com"); + EntityBareJid userJid = JidCreate.entityBareFrom("user@example.com"); + + AtomicBoolean updateRequestSent = new AtomicBoolean(); + InvokeDirectlyResponder serverSimulator = new InvokeDirectlyResponder() { + @Override + void verifyRequest(Message updateRequest) { + assertEquals(userJid, updateRequest.getTo(), "The provided JID doesn't match the request!"); + + GroupChatInvitation groupChatInvitation = (GroupChatInvitation) updateRequest.getExtension(GroupChatInvitation.NAMESPACE); + assertNotNull(groupChatInvitation, "Missing GroupChatInvitation extension"); + assertEquals(roomJid, groupChatInvitation.getRoomAddress()); + assertNull(groupChatInvitation.getReason()); + assertNull(groupChatInvitation.getPassword()); + assertFalse(groupChatInvitation.continueAsOneToOneChat()); + assertNull(groupChatInvitation.getThread()); + + updateRequestSent.set(true); + } + }; + serverSimulator.start(); + + // Create multi user chat + MultiUserChat multiUserChat = multiUserChatManager.getMultiUserChat(roomJid); + + // Call tested method + multiUserChat.inviteDirectly(userJid); + + // Wait for processing requests + serverSimulator.join(RESPONSE_TIMEOUT_IN_MILLIS); + + // Check if an error occurred within the simulator + final Throwable exception = serverSimulator.getException(); + if (exception != null) { + throw exception; + } + + assertTrue(updateRequestSent.get(), "Invite directly request not sent"); + } + + @Test + public void testInviteDirectlyWithAllOptionalAttributes() throws Throwable { + EntityBareJid roomJid = JidCreate.entityBareFrom("room@example.com"); + EntityBareJid userJid = JidCreate.entityBareFrom("user@example.com"); + String reason = "reason"; + String password = "password"; + boolean continueAsOneToOneChat = true; + String thread = "e0ffe42b28561960c6b12b944a092794b9683a38"; + + AtomicBoolean updateRequestSent = new AtomicBoolean(); + InvokeDirectlyResponder serverSimulator = new InvokeDirectlyResponder() { + @Override + void verifyRequest(Message updateRequest) { + assertEquals(userJid, updateRequest.getTo(), "The provided JID doesn't match the request!"); + + GroupChatInvitation groupChatInvitation = (GroupChatInvitation) updateRequest.getExtension(GroupChatInvitation.NAMESPACE); + assertNotNull(groupChatInvitation, "Missing GroupChatInvitation extension"); + assertEquals(roomJid, groupChatInvitation.getRoomAddress()); + assertSame(reason, groupChatInvitation.getReason()); + assertSame(password, groupChatInvitation.getPassword()); + assertSame(continueAsOneToOneChat, groupChatInvitation.continueAsOneToOneChat()); + assertSame(thread, groupChatInvitation.getThread()); + + updateRequestSent.set(true); + } + }; + serverSimulator.start(); + + // Create multi user chat + MultiUserChat multiUserChat = multiUserChatManager.getMultiUserChat(roomJid); + + // Call tested method + multiUserChat.inviteDirectly(userJid, reason, password, continueAsOneToOneChat, thread); + + // Wait for processing requests + serverSimulator.join(RESPONSE_TIMEOUT_IN_MILLIS); + + // Check if an error occurred within the simulator + final Throwable exception = serverSimulator.getException(); + if (exception != null) { + throw exception; + } + + assertTrue(updateRequestSent.get(), "Invite directly request not sent"); + } + + @Test + public void shouldReceiveOfflineInvitation() throws XmppStringprepException, Throwable { + EntityBareJid roomJid = JidCreate.entityBareFrom("room@example.com"); + EntityFullJid inviterJid = JidCreate.entityFullFrom("inviter@example.com/user1"); + EntityBareJid inviteeJid = JidCreate.entityBareFrom("invitee@example.com"); + Invite invite = new MUCUser.Invite(null, inviterJid); + + GroupChatInvitation groupChatInvitation = new GroupChatInvitation(roomJid); + Message sentMessage = connection.getStanzaFactory().buildMessageStanza() + .from(inviterJid) + .to(inviteeJid) + .addExtension(groupChatInvitation) + .build(); + + // Prepare listener to receive a group invitation + GroupInvitationListener groupInvitationListener = new GroupInvitationListener() { + + @Override + public void verifyInvitation(XMPPConnection conn, MultiUserChat room, EntityJid inviter, + String reason, String password, Message message, MUCUser.Invite invitation) { + try { + // Check all parameters' values. + assertSame(connection, conn); + assertSame(connection, room.getXmppConnection()); + assertEquals(roomJid, room.getRoom()); + assertEquals(inviterJid, inviter); + assertNull(reason); + assertNull(password); + assertSame(sentMessage, message); + assertEquals(invite.getReason(), invitation.getReason()); + assertEquals(invite.getFrom(), invitation.getFrom()); + } catch (final Throwable e) { + this.setError(e); + } + } + }; + + multiUserChatManager.addInvitationListener(groupInvitationListener); + + // Simulate sending a message with a group invitation + connection.processStanza(sentMessage); + + // Wait for the listener to be called or throw a timeout exception + groupInvitationListener.waitUntilInvocationOrTimeout(); + + if (groupInvitationListener.getError() != null) { + throw groupInvitationListener.getError(); + } + } + + @Test + public void shouldReceiveOfflineInvitationWithAllOptionalAttributes() throws XmppStringprepException, Throwable { + EntityBareJid roomJid = JidCreate.entityBareFrom("room@example.com"); + String expectedReason = "reason"; + String expectedPassword = "password"; + boolean expectedContinueAsOneToOneChat = true; + String expectedThread = "e0ffe42b28561960c6b12b944a092794b9683a38"; + EntityFullJid inviterJid = JidCreate.entityFullFrom("inviter@example.com/user1"); + EntityBareJid inviteeJid = JidCreate.entityBareFrom("invitee@example.com"); + Invite invite = new MUCUser.Invite(expectedReason, inviterJid); + + GroupChatInvitation groupChatInvitation = + new GroupChatInvitation(roomJid, expectedReason, expectedPassword, expectedContinueAsOneToOneChat, expectedThread); + Message sentMessage = connection.getStanzaFactory().buildMessageStanza() + .from(inviterJid) + .to(inviteeJid) + .addExtension(groupChatInvitation) + .build(); + + // Prepare listener to receive a group invitation + GroupInvitationListener groupInvitationListener = new GroupInvitationListener() { + @Override + public void verifyInvitation(XMPPConnection conn, MultiUserChat room, EntityJid inviter, + String reason, String password, Message message, MUCUser.Invite invitation) { + try { + // Check all parameters' values. + assertSame(connection, conn); + assertSame(connection, room.getXmppConnection()); + assertEquals(roomJid, room.getRoom()); + assertEquals(inviterJid, inviter); + assertEquals(expectedReason, reason); + assertEquals(expectedPassword, password); + assertSame(sentMessage, message); + assertEquals(invite.getReason(), invitation.getReason()); + assertEquals(invite.getFrom(), invitation.getFrom()); + } catch (final Throwable e) { + this.setError(e); + } + } + }; + + multiUserChatManager.addInvitationListener(groupInvitationListener); + + // Simulate sending a message with a group invitation + connection.processStanza(sentMessage); + + // Wait for the listener to be called or throw a timeout exception + groupInvitationListener.waitUntilInvocationOrTimeout(); + + if (groupInvitationListener.getError() != null) { + throw groupInvitationListener.getError(); + } + } + + /** + * This class can be used to simulate the server response for invoke directly request. + */ + private abstract class InvokeDirectlyResponder extends Thread { + protected Throwable exception; + abstract void verifyRequest(Message updateRequest); + + @Override + public void run() { + try { + while (true) { + final Stanza stanza = connection.getSentPacket(); + if (stanza instanceof Message) { + Message message = (Message) stanza; + verifyRequest(message); + break; + } + } + } + catch (Throwable e) { + exception = e; + } + } + + /** + * Returns the exception or error if something went wrong. + * + * @return the Throwable exception or error that occurred. + */ + Throwable getException() { + return exception; + } + } + + /** + * This class can be used to simulate receiving an invitation. + */ + private abstract static class GroupInvitationListener extends WaitForPacketListener implements InvitationListener { + protected volatile Throwable exception; + public abstract void verifyInvitation(XMPPConnection conn, MultiUserChat room, EntityJid inviter, + String reason, String password, Message message, MUCUser.Invite invitation); + + @Override + public void invitationReceived(XMPPConnection conn, MultiUserChat room, EntityJid inviter, + String reason, String password, Message message, MUCUser.Invite invitation) { + verifyInvitation(conn, room, inviter, reason, password, message, invitation); + reportInvoked(); + } + + public synchronized Throwable getError() { + return exception; + } + + public synchronized void setError(Throwable e) { + exception = e; + } + } +} diff --git a/smack-java8-full/src/main/java/org/jivesoftware/smackx/package-info.java b/smack-java8-full/src/main/java/org/jivesoftware/smackx/package-info.java index fec6f350c..8b3550028 100644 --- a/smack-java8-full/src/main/java/org/jivesoftware/smackx/package-info.java +++ b/smack-java8-full/src/main/java/org/jivesoftware/smackx/package-info.java @@ -352,6 +352,12 @@ * Efficient roster synchronization. * * + * Direct MUC Invitations + * XEP-0249 + * + * Allows sending a MUC invitation directly from the user to the contact with mediation by the room. + * + * * Message Carbons * XEP-0280 * {@link org.jivesoftware.smackx.carbons}