/* * Copyright (C) 2004-2008 Jive Software. All rights reserved. * * 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.openfire.muc.spi; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import org.dom4j.DocumentHelper; import org.dom4j.Element; import org.jivesoftware.openfire.PacketRouter; import org.jivesoftware.openfire.RoutingTable; import org.jivesoftware.openfire.XMPPServer; import org.jivesoftware.openfire.XMPPServerListener; import org.jivesoftware.openfire.auth.UnauthorizedException; import org.jivesoftware.openfire.cluster.ClusterManager; import org.jivesoftware.openfire.disco.DiscoInfoProvider; import org.jivesoftware.openfire.disco.DiscoItem; import org.jivesoftware.openfire.disco.DiscoItemsProvider; import org.jivesoftware.openfire.disco.DiscoServerItem; import org.jivesoftware.openfire.disco.ServerItemsProvider; import org.jivesoftware.openfire.event.GroupEventDispatcher; import org.jivesoftware.openfire.group.ConcurrentGroupList; import org.jivesoftware.openfire.group.GroupAwareList; import org.jivesoftware.openfire.group.GroupJID; import org.jivesoftware.openfire.handler.IQHandler; import org.jivesoftware.openfire.muc.HistoryStrategy; import org.jivesoftware.openfire.muc.MUCEventDelegate; import org.jivesoftware.openfire.muc.MUCEventDispatcher; import org.jivesoftware.openfire.muc.MUCRole; import org.jivesoftware.openfire.muc.MUCRoom; import org.jivesoftware.openfire.muc.MUCUser; import org.jivesoftware.openfire.muc.MultiUserChatService; import org.jivesoftware.openfire.muc.NotAllowedException; import org.jivesoftware.openfire.muc.cluster.GetNumberConnectedUsers; import org.jivesoftware.openfire.muc.cluster.OccupantAddedEvent; import org.jivesoftware.openfire.muc.cluster.RoomAvailableEvent; import org.jivesoftware.openfire.muc.cluster.RoomRemovedEvent; import org.jivesoftware.util.JiveGlobals; import org.jivesoftware.util.JiveProperties; import org.jivesoftware.util.LocaleUtils; import org.jivesoftware.util.TaskEngine; import org.jivesoftware.util.XMPPDateTimeFormat; import org.jivesoftware.util.cache.CacheFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmpp.component.Component; import org.xmpp.component.ComponentManager; import org.xmpp.forms.DataForm; import org.xmpp.forms.DataForm.Type; import org.xmpp.forms.FormField; import org.xmpp.packet.*; import org.xmpp.resultsetmanagement.ResultSet; /** * Implements the chat server as a cached memory resident chat server. The server is also * responsible for responding Multi-User Chat disco requests as well as removing inactive users from * the rooms after a period of time and to maintain a log of the conversation in the rooms that * require to log their conversations. The conversations log is saved to the database using a * separate process. * <p> * Temporary rooms are held in memory as long as they have occupants. They will be destroyed after * the last occupant left the room. On the other hand, persistent rooms are always present in memory * even after the last occupant left the room. In order to keep memory clean of persistent rooms that * have been forgotten or abandoned this class includes a clean up process. The clean up process * will remove from memory rooms that haven't had occupants for a while. Moreover, forgotten or * abandoned rooms won't be loaded into memory when the Multi-User Chat service starts up.</p> * * @author Gaston Dombiak */ public class MultiUserChatServiceImpl implements Component, MultiUserChatService, ServerItemsProvider, DiscoInfoProvider, DiscoItemsProvider, XMPPServerListener { private static final Logger Log = LoggerFactory.getLogger(MultiUserChatServiceImpl.class); /** * The time to elapse between clearing of idle chat users. */ private int user_timeout = 300000; /** * The number of milliseconds a user must be idle before he/she gets kicked from all the rooms. */ private int user_idle = -1; /** * Task that kicks idle users from the rooms. */ private UserTimeoutTask userTimeoutTask; /** * The time to elapse between logging the room conversations. */ private int log_timeout = 300000; /** * The number of messages to log on each run of the logging process. */ private int log_batch_size = 50; /** * Task that flushes room conversation logs to the database. */ private LogConversationTask logConversationTask; /** * the chat service's hostname (subdomain) */ private final String chatServiceName; /** * the chat service's description */ private String chatDescription = null; /** * LocalMUCRoom chat manager which supports simple chatroom management */ private LocalMUCRoomManager localMUCRoomManager = new LocalMUCRoomManager(); /** * Chat users managed by this manager. This includes only users connected to this JVM. * That means that when running inside of a cluster each node will have its own manager * that in turn will keep its own list of locally connected. * * table: key user jid (XMPPAddress); value ChatUser */ private Map<JID, LocalMUCUser> users = new ConcurrentHashMap<>(); private HistoryStrategy historyStrategy; private RoutingTable routingTable = null; /** * The packet router for the server. */ private PacketRouter router = null; /** * The handler of packets with namespace jabber:iq:register for the server. */ private IQMUCRegisterHandler registerHandler = null; /** * The handler of search requests ('jabber:iq:search' namespace). */ private IQMUCSearchHandler searchHandler = null; /** * Plugin (etc) provided IQ Handlers for MUC: */ private Map<String,IQHandler> iqHandlers = null; /** * The total time all agents took to chat * */ public long totalChatTime; /** * Flag that indicates if the service should provide information about locked rooms when * handling service discovery requests. * Note: Setting this flag in false is not compliant with the spec. A user may try to join a * locked room thinking that the room doesn't exist because the user didn't discover it before. */ private boolean allowToDiscoverLockedRooms = true; /** * Flag that indicates if the service should provide information about non-public members-only * rooms when handling service discovery requests. */ private boolean allowToDiscoverMembersOnlyRooms = false; /** * Returns the permission policy for creating rooms. A true value means that not anyone can * create a room, only the JIDs listed in <code>allowedToCreate</code> are allowed to create * rooms. */ private boolean roomCreationRestricted = false; /** * Bare jids of users that are allowed to create MUC rooms. An empty list means that anyone can * create a room. Might also include group jids. */ private GroupAwareList<JID> allowedToCreate = new ConcurrentGroupList<>(); /** * Bare jids of users that are system administrators of the MUC service. A sysadmin has the same * permissions as a room owner. Might also contain group jids. */ private GroupAwareList<JID> sysadmins = new ConcurrentGroupList<>(); /** * Queue that holds the messages to log for the rooms that need to log their conversations. */ private Queue<ConversationLogEntry> logQueue = new LinkedBlockingQueue<>(100000); /** * Max number of hours that a persistent room may be empty before the service removes the * room from memory. Unloaded rooms will exist in the database and may be loaded by a user * request. Default time limit is: 30 days. */ private long emptyLimit = 30 * 24; /** * Task that removes rooms from memory that have been without activity for a period of time. A * room is considered without activity when no occupants are present in the room for a while. */ private CleanupTask cleanupTask; /** * The time to elapse between each rooms cleanup. Default frequency is 60 minutes. */ private static final long CLEANUP_FREQUENCY = 60 * 60 * 1000; /** * Total number of received messages in all rooms since the last reset. The counter * is reset each time the Statistic makes a sampling. */ private AtomicInteger inMessages = new AtomicInteger(0); /** * Total number of broadcasted messages in all rooms since the last reset. The counter * is reset each time the Statistic makes a sampling. */ private AtomicLong outMessages = new AtomicLong(0); /** * Flag that indicates if MUC service is enabled. */ private boolean serviceEnabled = true; /** * Flag that indicates if MUC service is hidden from services views. */ private boolean isHidden = true; /** * Delegate responds to events for the MUC service. */ protected MUCEventDelegate mucEventDelegate; /** * Additional features to be added to the disco response for the service. */ private List<String> extraDiscoFeatures = new ArrayList<>(); /** * Additional identities to be added to the disco response for the service. */ private List<Element> extraDiscoIdentities = new ArrayList<>(); /** * Create a new group chat server. * * @param subdomain * Subdomain portion of the conference services (for example, * conference for conference.example.org) * @param description * Short description of service for disco and such. If * <tt>null</tt> or empty, a default value will be used. * @param isHidden * True if this service should be hidden from services views. * @throws IllegalArgumentException * if the provided subdomain is an invalid, according to the JID * domain definition. */ public MultiUserChatServiceImpl(String subdomain, String description, Boolean isHidden) { // Check subdomain and throw an IllegalArgumentException if its invalid new JID(null,subdomain + "." + XMPPServer.getInstance().getServerInfo().getXMPPDomain(), null); this.chatServiceName = subdomain; if (description != null && description.trim().length() > 0) { this.chatDescription = description; } else { this.chatDescription = LocaleUtils.getLocalizedString("muc.service-name"); } this.isHidden = isHidden; historyStrategy = new HistoryStrategy(null); } @Override public void addIQHandler(IQHandler iqHandler) { if (this.iqHandlers == null) { this.iqHandlers = new HashMap<>(); } this.iqHandlers.put(iqHandler.getInfo().getNamespace(), iqHandler); } @Override public void removeIQHandler(IQHandler iqHandler) { if (this.iqHandlers != null) { if (iqHandler == this.iqHandlers.get(iqHandler.getInfo().getNamespace())) { this.iqHandlers.remove(iqHandler.getInfo().getNamespace()); } } } @Override public String getDescription() { return chatDescription; } public void setDescription(String desc) { this.chatDescription = desc; } @Override public void processPacket(Packet packet) { if (!isServiceEnabled()) { return; } // The MUC service will receive all the packets whose domain matches the domain of the MUC // service. This means that, for instance, a disco request should be responded by the // service itself instead of relying on the server to handle the request. try { // Check if the packet is a disco request or a packet with namespace iq:register if (packet instanceof IQ) { if (process((IQ)packet)) { return; } } else if (packet instanceof Message) { Message msg = (Message) packet; if (msg.getType() == Message.Type.error) { // Bounced message, drop user. removeUser(packet.getFrom()); return; } } else if (packet instanceof Presence) { Presence pres = (Presence) packet; if (pres.getType() == Presence.Type.error) { // Bounced presence, drop user. removeUser(packet.getFrom()); return; } } if ( packet.getTo().getNode() == null ) { // This was addressed at the service itself, which by now should have been handled. if ( packet instanceof IQ && ((IQ) packet).isRequest() ) { final IQ reply = IQ.createResultIQ( (IQ) packet ); reply.setChildElement( ((IQ) packet).getChildElement().createCopy() ); reply.setError( PacketError.Condition.feature_not_implemented ); router.route( reply ); } Log.debug( "Ignoring stanza addressed at conference service: {}", packet.toXML() ); } else { // The packet is a normal packet that should possibly be sent to the room JID recipient = packet.getTo(); String roomName = recipient != null ? recipient.getNode() : null; getChatUser( packet.getFrom(), roomName ).process( packet ); } } catch (Exception e) { Log.error(LocaleUtils.getLocalizedString("admin.error"), e); } } /** * Returns true if the IQ packet was processed. This method should only process disco packets * as well as jabber:iq:register packets sent to the MUC service. * * @param iq the IQ packet to process. * @return true if the IQ packet was processed. */ private boolean process(IQ iq) { Element childElement = iq.getChildElement(); String namespace = null; // Ignore IQs of type ERROR if (IQ.Type.error == iq.getType()) { return false; } if (iq.getTo().getResource() != null) { // Ignore IQ packets sent to room occupants return false; } if (childElement != null) { namespace = childElement.getNamespaceURI(); } if ("jabber:iq:register".equals(namespace)) { IQ reply = registerHandler.handleIQ(iq); router.route(reply); } else if ("jabber:iq:search".equals(namespace)) { IQ reply = searchHandler.handleIQ(iq); router.route(reply); } else if ("http://jabber.org/protocol/disco#info".equals(namespace)) { // TODO MUC should have an IQDiscoInfoHandler of its own when MUC becomes // a component IQ reply = XMPPServer.getInstance().getIQDiscoInfoHandler().handleIQ(iq); router.route(reply); } else if ("http://jabber.org/protocol/disco#items".equals(namespace)) { // TODO MUC should have an IQDiscoItemsHandler of its own when MUC becomes // a component IQ reply = XMPPServer.getInstance().getIQDiscoItemsHandler().handleIQ(iq); router.route(reply); } else if ("urn:xmpp:ping".equals(namespace)) { router.route( IQ.createResultIQ(iq) ); } else if (this.iqHandlers != null) { IQHandler h = this.iqHandlers.get(namespace); if (h != null) { try { IQ reply = h.handleIQ(iq); if (reply != null) { router.route(reply); } } catch (UnauthorizedException e) { IQ reply = IQ.createResultIQ(iq); reply.setType(IQ.Type.error); reply.setError(PacketError.Condition.service_unavailable); router.route(reply); } return true; } return false; } else { return false; } return true; } @Override public void initialize(JID jid, ComponentManager componentManager) { initialize(XMPPServer.getInstance()); } @Override public void shutdown() { enableService( false, false ); } @Override public String getServiceDomain() { return chatServiceName + "." + XMPPServer.getInstance().getServerInfo().getXMPPDomain(); } public JID getAddress() { return new JID(null, getServiceDomain(), null, true); } @Override public void serverStarted() {} @Override public void serverStopping() { // When this is executed, we can be certain that all server modules have not yet shut down. This allows us to // inform all users. shutdown(); } /** * Probes the presence of any user who's last packet was sent more than 5 minute ago. */ private class UserTimeoutTask extends TimerTask { /** * Remove any user that has been idle for longer than the user timeout time. */ @Override public void run() { checkForTimedOutUsers(); } } /** * Informs all users local to this cluster node that he or she is being removed from the room because the MUC * service is being shut down. * * The implementation is optimized to run as fast as possible (to prevent prolonging the shutdown). */ private void broadcastShutdown() { Log.debug( "Notifying all local users about the imminent destruction of chat service '{}'", chatServiceName ); if (users.isEmpty()) { return; } // A thread pool is used to broadcast concurrently, as well as to limit the execution time of this service. final ExecutorService service = Executors.newFixedThreadPool( Math.min( users.size(), 10 ) ); // Queue all tasks in the executor service. for ( final LocalMUCUser user : users.values() ) { // Submit a concurrent task for each local user (that could be in more than one (local) room). service.submit( new Runnable() { @Override public void run() { try { for ( final LocalMUCRole role : user.getRoles() ) { final MUCRoom room = role.getChatRoom(); // Send a presence stanza of type "unavailable" to the occupant final Presence presence = room.createPresence( Presence.Type.unavailable ); presence.setFrom( role.getRoleAddress() ); // A fragment containing the x-extension. final Element fragment = presence.addChildElement( "x", "http://jabber.org/protocol/muc#user" ); final Element item = fragment.addElement( "item" ); item.addAttribute( "affiliation", "none" ); item.addAttribute( "role", "none" ); fragment.addElement( "status" ).addAttribute( "code", "332" ); // Make sure that the presence change for each user is only sent to that user (and not broadcasted in the room)! role.send( presence ); } } catch ( Exception e ) { Log.debug( "Unable to inform {} about the imminent destruction of chat service '{}'", user.getAddress(), chatServiceName, e ); } } } ); } // Try to shutdown - wait - force shutdown. service.shutdown(); try { service.awaitTermination( JiveGlobals.getIntProperty( "xmpp.muc.await-termination-millis", 500 ), TimeUnit.MILLISECONDS ); Log.debug( "Successfully notified all {} local users about the imminent destruction of chat service '{}'", users.size(), chatServiceName ); } catch ( InterruptedException e ) { Log.debug( "Interrupted while waiting for all users to be notified of shutdown of chat service '{}'. Shutting down immediately.", chatServiceName ); } service.shutdownNow(); } private void checkForTimedOutUsers() { final long deadline = System.currentTimeMillis() - user_idle; for (LocalMUCUser user : users.values()) { try { // If user is not present in any room then remove the user from // the list of users if (!user.isJoined()) { removeUser(user.getAddress()); continue; } // Do nothing if this feature is disabled (i.e USER_IDLE equals -1) if (user_idle == -1) { continue; } if (user.getLastPacketTime() < deadline) { // Kick the user from all the rooms that he/she had previuosly joined MUCRoom room; Presence kickedPresence; for (LocalMUCRole role : user.getRoles()) { room = role.getChatRoom(); try { kickedPresence = room.kickOccupant(user.getAddress(), null, null, null); // Send the updated presence to the room occupants room.send(kickedPresence); } catch (NotAllowedException e) { // Do nothing since we cannot kick owners or admins } } } } catch (Throwable e) { Log.error(LocaleUtils.getLocalizedString("admin.error"), e); } } } /** * Logs the conversation of the rooms that have this feature enabled. */ private class LogConversationTask extends TimerTask { @Override public void run() { try { logConversation(); } catch (Throwable e) { Log.error(LocaleUtils.getLocalizedString("admin.error"), e); } } } private void logConversation() { ConversationLogEntry entry; boolean success; for (int index = 0; index <= log_batch_size && !logQueue.isEmpty(); index++) { entry = logQueue.poll(); if (entry != null) { success = MUCPersistenceManager.saveConversationLogEntry(entry); if (!success) { logQueue.add(entry); } } } } /** * Logs all the remaining conversation log entries to the database. Use this method to force * saving all the conversation log entries before the service becomes unavailable. */ private void logAllConversation() { ConversationLogEntry entry; while (!logQueue.isEmpty()) { entry = logQueue.poll(); if (entry != null) { MUCPersistenceManager.saveConversationLogEntry(entry); } } } /** * Removes from memory rooms that have been without activity for a period of time. A room is * considered without activity when no occupants are present in the room for a while. */ private class CleanupTask extends TimerTask { @Override public void run() { if (ClusterManager.isClusteringStarted() && !ClusterManager.isSeniorClusterMember()) { // Do nothing if we are in a cluster and this JVM is not the senior cluster member return; } try { localMUCRoomManager.cleanupRooms(getCleanupDate()); } catch (Throwable e) { Log.error(LocaleUtils.getLocalizedString("admin.error"), e); } } } @Override public MUCRoom getChatRoom(String roomName, JID userjid) throws NotAllowedException { LocalMUCRoom room; boolean loaded = false; boolean created = false; synchronized (roomName.intern()) { room = localMUCRoomManager.getRoom(roomName); if (room == null) { room = new LocalMUCRoom(this, roomName, router); // If the room is persistent load the configuration values from the DB try { // Try to load the room's configuration from the database (if the room is // persistent but was added to the DB after the server was started up or the // room may be an old room that was not present in memory) MUCPersistenceManager.loadFromDB(room); loaded = true; } catch (IllegalArgumentException e) { // Check if room needs to be recreated in case it failed to be created previously // (or was deleted somehow and is expected to exist by a delegate). if (mucEventDelegate != null && mucEventDelegate.shouldRecreate(roomName, userjid)) { if (mucEventDelegate.loadConfig(room)) { loaded = true; if (room.isPersistent()) { MUCPersistenceManager.saveToDB(room); } } else { // Room does not exist and delegate does not recognize it and does // not allow room creation throw new NotAllowedException(); } } else { // The room does not exist so check for creation permissions // Room creation is always allowed for sysadmin final JID bareJID = userjid.asBareJID(); if (isRoomCreationRestricted() && !sysadmins.includes(bareJID)) { // The room creation is only allowed for certain JIDs if (!allowedToCreate.includes(bareJID)) { // The user is not in the list of allowed JIDs to create a room so raise // an exception throw new NotAllowedException(); } } room.addFirstOwner(userjid); created = true; } } localMUCRoomManager.addRoom(roomName, room); } } if (created) { // Fire event that a new room has been created MUCEventDispatcher.roomCreated(room.getRole().getRoleAddress()); } if (loaded || created) { // Notify other cluster nodes that a new room is available CacheFactory.doClusterTask(new RoomAvailableEvent(room)); for (MUCRole role : room.getOccupants()) { if (role instanceof LocalMUCRole) { CacheFactory.doClusterTask(new OccupantAddedEvent(room, role)); } } } return room; } @Override public MUCRoom getChatRoom(String roomName) { boolean loaded = false; LocalMUCRoom room = localMUCRoomManager.getRoom(roomName); if (room == null) { // Check if the room exists in the databclase and was not present in memory synchronized (roomName.intern()) { room = localMUCRoomManager.getRoom(roomName); if (room == null) { room = new LocalMUCRoom(this, roomName, router); // If the room is persistent load the configuration values from the DB try { // Try to load the room's configuration from the database (if the room is // persistent but was added to the DB after the server was started up or the // room may be an old room that was not present in memory) MUCPersistenceManager.loadFromDB(room); loaded = true; localMUCRoomManager.addRoom(roomName,room); } catch (IllegalArgumentException e) { // The room does not exist so do nothing room = null; } } } } if (loaded) { // Notify other cluster nodes that a new room is available CacheFactory.doClusterTask(new RoomAvailableEvent(room)); } return room; } @Override public void refreshChatRoom(String roomName) { localMUCRoomManager.removeRoom(roomName); getChatRoom(roomName); } public LocalMUCRoom getLocalChatRoom(String roomName) { return localMUCRoomManager.getRoom(roomName); } @Override public List<MUCRoom> getChatRooms() { return new ArrayList<MUCRoom>(localMUCRoomManager.getRooms()); } @Override public boolean hasChatRoom(String roomName) { return getChatRoom(roomName) != null; } @Override public void removeChatRoom(String roomName) { removeChatRoom(roomName, true); } /** * Notification message indicating that the specified chat room was * removed from some other cluster member. * * @param room the removed room in another cluster node. */ @Override public void chatRoomRemoved(LocalMUCRoom room) { removeChatRoom(room.getName(), false); } /** * Notification message indicating that a chat room has been created * in another cluster member. * * @param room the created room in another cluster node. */ @Override public void chatRoomAdded(LocalMUCRoom room) { localMUCRoomManager.addRoom(room.getName(), room) ; } private void removeChatRoom(String roomName, boolean notify) { MUCRoom room = localMUCRoomManager.removeRoom(roomName); if (room != null) { Log.info("removing chat room:" + roomName + "|" + room.getClass().getName()); if (room instanceof LocalMUCRoom) GroupEventDispatcher.removeListener((LocalMUCRoom) room); totalChatTime += room.getChatLength(); if (notify) { // Notify other cluster nodes that a room has been removed CacheFactory.doClusterTask(new RoomRemovedEvent((LocalMUCRoom)room)); } } else { Log.info("No chatroom {} during removal.", roomName); } } @Override public String getServiceName() { return chatServiceName; } @Override public String getName() { return getServiceName(); } @Override public HistoryStrategy getHistoryStrategy() { return historyStrategy; } /** * Removes a user from all chat rooms. * * @param jabberID The user's normal jid, not the chat nickname jid. */ private void removeUser(JID jabberID) { LocalMUCUser user = users.remove(jabberID); if (user != null) { for (LocalMUCRole role : user.getRoles()) { try { role.getChatRoom().leaveRoom(role); } catch (Exception e) { Log.error(e.getMessage(), e); } } } } /** * Obtain a chat user by XMPPAddress. Only returns users that are connected to this JVM. * * @param userjid The XMPPAddress of the user. * @param roomName name of the room to receive the packet. * @return The chatuser corresponding to that XMPPAddress. */ private MUCUser getChatUser(JID userjid, String roomName) { if (router == null) { throw new IllegalStateException("Not initialized"); } LocalMUCUser user; synchronized (userjid.toString().intern()) { user = users.get(userjid); if (user == null) { if (roomName != null) { // Check if the JID belong to a user hosted in another cluster node LocalMUCRoom localMUCRoom = localMUCRoomManager.getRoom(roomName); if (localMUCRoom != null) { MUCRole occupant = localMUCRoom.getOccupantByFullJID(userjid); if (occupant != null && !occupant.isLocal()) { return new RemoteMUCUser(userjid, localMUCRoom); } } } user = new LocalMUCUser(this, router, userjid); users.put(userjid, user); } } return user; } @Override public Collection<MUCRole> getMUCRoles(JID user) { List<MUCRole> userRoles = new ArrayList<>(); for (LocalMUCRoom room : localMUCRoomManager.getRooms()) { MUCRole role = room.getOccupantByFullJID(user); if (role != null) { userRoles.add(role); } } return userRoles; } /** * Returns the limit date after which rooms without activity will be removed from memory. * * @return the limit date after which rooms without activity will be removed from memory. */ private Date getCleanupDate() { return new Date(System.currentTimeMillis() - (emptyLimit * 3600000)); } @Override public void setKickIdleUsersTimeout(int timeout) { if (this.user_timeout == timeout) { return; } // Cancel the existing task because the timeout has changed if (userTimeoutTask != null) { userTimeoutTask.cancel(); } this.user_timeout = timeout; // Create a new task and schedule it with the new timeout userTimeoutTask = new UserTimeoutTask(); TaskEngine.getInstance().schedule(userTimeoutTask, user_timeout, user_timeout); // Set the new property value MUCPersistenceManager.setProperty(chatServiceName, "tasks.user.timeout", Integer.toString(timeout)); } @Override public int getKickIdleUsersTimeout() { return user_timeout; } @Override public void setUserIdleTime(int idleTime) { if (this.user_idle == idleTime) { return; } this.user_idle = idleTime; // Set the new property value MUCPersistenceManager.setProperty(chatServiceName, "tasks.user.idle", Integer.toString(idleTime)); } @Override public int getUserIdleTime() { return user_idle; } @Override public void setLogConversationsTimeout(int timeout) { if (this.log_timeout == timeout) { return; } // Cancel the existing task because the timeout has changed if (logConversationTask != null) { logConversationTask.cancel(); } this.log_timeout = timeout; // Create a new task and schedule it with the new timeout logConversationTask = new LogConversationTask(); TaskEngine.getInstance().schedule(logConversationTask, log_timeout, log_timeout); // Set the new property value MUCPersistenceManager.setProperty(chatServiceName, "tasks.log.timeout", Integer.toString(timeout)); } @Override public int getLogConversationsTimeout() { return log_timeout; } @Override public void setLogConversationBatchSize(int size) { if (this.log_batch_size == size) { return; } this.log_batch_size = size; // Set the new property value MUCPersistenceManager.setProperty(chatServiceName, "tasks.log.batchsize", Integer.toString(size)); } @Override public int getLogConversationBatchSize() { return log_batch_size; } @Override public Collection<JID> getUsersAllowedToCreate() { return Collections.unmodifiableCollection(allowedToCreate); } @Override public Collection<JID> getSysadmins() { return Collections.unmodifiableCollection(sysadmins); } @Override public boolean isSysadmin(JID bareJID) { return sysadmins.includes(bareJID); } @Override public void addSysadmins(Collection<JID> userJIDs) { for (JID userJID : userJIDs) { addSysadmin(userJID); } } @Override public void addSysadmin(JID userJID) { final JID bareJID = userJID.asBareJID(); if (!sysadmins.contains(userJID)) { sysadmins.add(bareJID); } // CopyOnWriteArray does not allow sorting, so do sorting in temp list. ArrayList<JID> tempList = new ArrayList<>(sysadmins); Collections.sort(tempList); sysadmins = new ConcurrentGroupList<>(tempList); // Update the config. String[] jids = new String[sysadmins.size()]; for (int i = 0; i < jids.length; i++) { jids[i] = sysadmins.get(i).toBareJID(); } MUCPersistenceManager.setProperty(chatServiceName, "sysadmin.jid", fromArray(jids)); } @Override public void removeSysadmin(JID userJID) { final JID bareJID = userJID.asBareJID(); sysadmins.remove(bareJID); // Update the config. String[] jids = new String[sysadmins.size()]; for (int i = 0; i < jids.length; i++) { jids[i] = sysadmins.get(i).toBareJID(); } MUCPersistenceManager.setProperty(chatServiceName, "sysadmin.jid", fromArray(jids)); } /** * Returns the flag that indicates if the service should provide information about non-public * members-only rooms when handling service discovery requests. * * @return true if the service should provide information about non-public members-only rooms. */ public boolean isAllowToDiscoverMembersOnlyRooms() { return allowToDiscoverMembersOnlyRooms; } /** * Sets the flag that indicates if the service should provide information about non-public * members-only rooms when handling service discovery requests. * * @param allowToDiscoverMembersOnlyRooms * if the service should provide information about * non-public members-only rooms. */ public void setAllowToDiscoverMembersOnlyRooms(boolean allowToDiscoverMembersOnlyRooms) { this.allowToDiscoverMembersOnlyRooms = allowToDiscoverMembersOnlyRooms; MUCPersistenceManager.setProperty(chatServiceName, "discover.membersOnly", Boolean.toString(allowToDiscoverMembersOnlyRooms)); } /** * Returns the flag that indicates if the service should provide information about locked rooms * when handling service discovery requests. * * @return true if the service should provide information about locked rooms. */ public boolean isAllowToDiscoverLockedRooms() { return allowToDiscoverLockedRooms; } /** * Sets the flag that indicates if the service should provide information about locked rooms * when handling service discovery requests. * Note: Setting this flag in false is not compliant with the spec. A user may try to join a * locked room thinking that the room doesn't exist because the user didn't discover it before. * * @param allowToDiscoverLockedRooms if the service should provide information about locked * rooms. */ public void setAllowToDiscoverLockedRooms(boolean allowToDiscoverLockedRooms) { this.allowToDiscoverLockedRooms = allowToDiscoverLockedRooms; MUCPersistenceManager.setProperty(chatServiceName, "discover.locked", Boolean.toString(allowToDiscoverLockedRooms)); } @Override public boolean isRoomCreationRestricted() { return roomCreationRestricted; } @Override public void setRoomCreationRestricted(boolean roomCreationRestricted) { this.roomCreationRestricted = roomCreationRestricted; MUCPersistenceManager.setProperty(chatServiceName, "create.anyone", Boolean.toString(roomCreationRestricted)); } @Override public void addUsersAllowedToCreate(Collection<JID> userJIDs) { boolean listChanged = false; for(JID userJID: userJIDs) { // Update the list of allowed JIDs to create MUC rooms. Since we are updating the instance // variable there is no need to restart the service if (!allowedToCreate.contains(userJID)) { allowedToCreate.add(userJID); listChanged = true; } } // if nothing was added, there's nothing to update if(listChanged) { // CopyOnWriteArray does not allow sorting, so do sorting in temp list. List<JID> tempList = new ArrayList<>(allowedToCreate); Collections.sort(tempList); allowedToCreate = new ConcurrentGroupList<>(tempList); // Update the config. MUCPersistenceManager.setProperty(chatServiceName, "create.jid", fromCollection(allowedToCreate)); } } @Override public void addUserAllowedToCreate(JID userJID) { List<JID> asList = new ArrayList<>(); asList.add(userJID); addUsersAllowedToCreate(asList); } @Override public void removeUsersAllowedToCreate(Collection<JID> userJIDs) { boolean listChanged = false; for(JID userJID: userJIDs) { // Update the list of allowed JIDs to create MUC rooms. Since we are updating the instance // variable there is no need to restart the service listChanged |= allowedToCreate.remove(userJID); } // if none of the JIDs were on the list, there's nothing to update if(listChanged) { MUCPersistenceManager.setProperty(chatServiceName, "create.jid", fromCollection(allowedToCreate)); } } @Override public void removeUserAllowedToCreate(JID userJID) { removeUsersAllowedToCreate(Collections.singleton(userJID)); } public void initialize(XMPPServer server) { initializeSettings(); routingTable = server.getRoutingTable(); router = server.getPacketRouter(); // Configure the handler of iq:register packets registerHandler = new IQMUCRegisterHandler(this); // Configure the handler of jabber:iq:search packets searchHandler = new IQMUCSearchHandler(this); } public void initializeSettings() { serviceEnabled = JiveProperties.getInstance().getBooleanProperty("xmpp.muc.enabled", true); serviceEnabled = MUCPersistenceManager.getBooleanProperty(chatServiceName, "enabled", serviceEnabled); // Trigger the strategy to load itself from the context historyStrategy.setContext(chatServiceName, "history"); // Load the list of JIDs that are sysadmins of the MUC service String property = MUCPersistenceManager.getProperty(chatServiceName, "sysadmin.jid"); sysadmins.clear(); if (property != null && property.trim().length() > 0) { final String[] jids = property.split(","); for (String jid : jids) { if (jid == null || jid.trim().length() == 0) { continue; } try { // could be a group jid sysadmins.add(GroupJID.fromString(jid.trim().toLowerCase()).asBareJID()); } catch (IllegalArgumentException e) { Log.warn("The 'sysadmin.jid' property contains a value that is not a valid JID. It is ignored. Offending value: '" + jid + "'.", e); } } } allowToDiscoverLockedRooms = MUCPersistenceManager.getBooleanProperty(chatServiceName, "discover.locked", true); allowToDiscoverMembersOnlyRooms = MUCPersistenceManager.getBooleanProperty(chatServiceName, "discover.membersOnly", true); roomCreationRestricted = MUCPersistenceManager.getBooleanProperty(chatServiceName, "create.anyone", false); // Load the list of JIDs that are allowed to create a MUC room property = MUCPersistenceManager.getProperty(chatServiceName, "create.jid"); allowedToCreate.clear(); if (property != null && property.trim().length() > 0) { final String[] jids = property.split(","); for (String jid : jids) { if (jid == null || jid.trim().length() == 0) { continue; } try { // could be a group jid allowedToCreate.add(GroupJID.fromString(jid.trim().toLowerCase()).asBareJID()); } catch (IllegalArgumentException e) { Log.warn("The 'create.jid' property contains a value that is not a valid JID. It is ignored. Offending value: '" + jid + "'.", e); } } } String value = MUCPersistenceManager.getProperty(chatServiceName, "tasks.user.timeout"); user_timeout = 300000; if (value != null) { try { user_timeout = Integer.parseInt(value); } catch (NumberFormatException e) { Log.error("Wrong number format of property tasks.user.timeout for service "+chatServiceName, e); } } value = MUCPersistenceManager.getProperty(chatServiceName, "tasks.user.idle"); user_idle = -1; if (value != null) { try { user_idle = Integer.parseInt(value); } catch (NumberFormatException e) { Log.error("Wrong number format of property tasks.user.idle for service "+chatServiceName, e); } } value = MUCPersistenceManager.getProperty(chatServiceName, "tasks.log.timeout"); log_timeout = 300000; if (value != null) { try { log_timeout = Integer.parseInt(value); } catch (NumberFormatException e) { Log.error("Wrong number format of property tasks.log.timeout for service "+chatServiceName, e); } } value = MUCPersistenceManager.getProperty(chatServiceName, "tasks.log.batchsize"); log_batch_size = 50; if (value != null) { try { log_batch_size = Integer.parseInt(value); } catch (NumberFormatException e) { Log.error("Wrong number format of property tasks.log.batchsize for service "+chatServiceName, e); } } value = MUCPersistenceManager.getProperty(chatServiceName, "unload.empty_days"); emptyLimit = 30 * 24; if (value != null) { try { emptyLimit = Integer.parseInt(value) * 24; } catch (NumberFormatException e) { Log.error("Wrong number format of property unload.empty_days for service "+chatServiceName, e); } } } @Override public void start() { XMPPServer.getInstance().addServerListener( this ); // Run through the users every 5 minutes after a 5 minutes server startup delay (default // values) userTimeoutTask = new UserTimeoutTask(); TaskEngine.getInstance().schedule(userTimeoutTask, user_timeout, user_timeout); // Log the room conversations every 5 minutes after a 5 minutes server startup delay // (default values) logConversationTask = new LogConversationTask(); TaskEngine.getInstance().schedule(logConversationTask, log_timeout, log_timeout); // Remove unused rooms from memory cleanupTask = new CleanupTask(); TaskEngine.getInstance().schedule(cleanupTask, CLEANUP_FREQUENCY, CLEANUP_FREQUENCY); // Set us up to answer disco item requests XMPPServer.getInstance().getIQDiscoItemsHandler().addServerItemsProvider(this); XMPPServer.getInstance().getIQDiscoInfoHandler().setServerNodeInfoProvider(this.getServiceDomain(), this); XMPPServer.getInstance().getServerItemsProviders().add(this); ArrayList<String> params = new ArrayList<>(); params.clear(); params.add(getServiceDomain()); Log.info(LocaleUtils.getLocalizedString("startup.starting.muc", params)); // Load all the persistent rooms to memory for (LocalMUCRoom room : MUCPersistenceManager.loadRoomsFromDB(this, this.getCleanupDate(), router)) { localMUCRoomManager.addRoom(room.getName().toLowerCase(),room); } } private void stop() { XMPPServer.getInstance().getIQDiscoItemsHandler().removeServerItemsProvider(this); XMPPServer.getInstance().getIQDiscoInfoHandler().removeServerNodeInfoProvider(this.getServiceDomain()); XMPPServer.getInstance().getServerItemsProviders().remove(this); // Remove the route to this service routingTable.removeComponentRoute(getAddress()); broadcastShutdown(); logAllConversation(); XMPPServer.getInstance().removeServerListener( this ); } @Override public void enableService(boolean enabled, boolean persistent) { if (isServiceEnabled() == enabled) { // Do nothing if the service status has not changed return; } if (!enabled) { // Stop the service/module stop(); } if (persistent) { MUCPersistenceManager.setProperty(chatServiceName, "enabled", Boolean.toString(enabled)); } serviceEnabled = enabled; if (enabled) { // Start the service/module start(); } } @Override public boolean isServiceEnabled() { return serviceEnabled; } @Override public long getTotalChatTime() { return totalChatTime; } /** * Retuns the number of existing rooms in the server (i.e. persistent or not, * in memory or not). * * @return the number of existing rooms in the server. */ @Override public int getNumberChatRooms() { return localMUCRoomManager.getNumberChatRooms(); } /** * Retuns the total number of occupants in all rooms in the server. * * @param onlyLocal true if only users connected to this JVM will be considered. Otherwise count cluster wise. * @return the number of existing rooms in the server. */ @Override public int getNumberConnectedUsers(boolean onlyLocal) { int total = 0; for (LocalMUCUser user : users.values()) { if (user.isJoined()) { total = total + 1; } } // Add users from remote cluster nodes if (!onlyLocal) { Collection<Object> results = CacheFactory.doSynchronousClusterTask(new GetNumberConnectedUsers(), false); for (Object result : results) { if (result == null) { continue; } total = total + (Integer) result; } } return total; } /** * Retuns the total number of users that have joined in all rooms in the server. * * @return the number of existing rooms in the server. */ @Override public int getNumberRoomOccupants() { int total = 0; for (MUCRoom room : localMUCRoomManager.getRooms()) { total = total + room.getOccupantsCount(); } return total; } /** * Returns the total number of incoming messages since last reset. * * @param resetAfter True if you want the counter to be reset after results returned. * @return the number of incoming messages through the service. */ @Override public long getIncomingMessageCount(boolean resetAfter) { if (resetAfter) { return inMessages.getAndSet(0); } else { return inMessages.get(); } } /** * Returns the total number of outgoing messages since last reset. * * @param resetAfter True if you want the counter to be reset after results returned. * @return the number of outgoing messages through the service. */ @Override public long getOutgoingMessageCount(boolean resetAfter) { if (resetAfter) { return outMessages.getAndSet(0); } else { return outMessages.get(); } } @Override public void logConversation(MUCRoom room, Message message, JID sender) { // Only log messages that have a subject or body. Otherwise ignore it. if (message.getSubject() != null || message.getBody() != null) { logQueue.add(new ConversationLogEntry(new Date(), room, message, sender)); } } @Override public void messageBroadcastedTo(int numOccupants) { // Increment counter of received messages that where broadcasted by one inMessages.incrementAndGet(); // Increment counter of outgoing messages with the number of room occupants // that received the message outMessages.addAndGet(numOccupants); } @Override public Iterator<DiscoServerItem> getItems() { // Check if the service is disabled. Info is not available when // disabled. if (!isServiceEnabled()) { return null; } final ArrayList<DiscoServerItem> items = new ArrayList<>(); final DiscoServerItem item = new DiscoServerItem(new JID( getServiceDomain()), getDescription(), null, null, this, this); items.add(item); return items.iterator(); } @Override public Iterator<Element> getIdentities(String name, String node, JID senderJID) { ArrayList<Element> identities = new ArrayList<>(); if (name == null && node == null) { // Answer the identity of the MUC service Element identity = DocumentHelper.createElement("identity"); identity.addAttribute("category", "conference"); identity.addAttribute("name", getDescription()); identity.addAttribute("type", "text"); identities.add(identity); // TODO: Should internationalize Public Chatroom Search, and make it configurable. Element searchId = DocumentHelper.createElement("identity"); searchId.addAttribute("category", "directory"); searchId.addAttribute("name", "Public Chatroom Search"); searchId.addAttribute("type", "chatroom"); identities.add(searchId); if (!extraDiscoIdentities.isEmpty()) { identities.addAll(extraDiscoIdentities); } } else if (name != null && node == null) { // Answer the identity of a given room MUCRoom room = getChatRoom(name); if (room != null) { Element identity = DocumentHelper.createElement("identity"); identity.addAttribute("category", "conference"); identity.addAttribute("name", room.getNaturalLanguageName()); identity.addAttribute("type", "text"); identities.add(identity); } } else if (name != null && "x-roomuser-item".equals(node)) { // Answer reserved nickname for the sender of the disco request in the requested room MUCRoom room = getChatRoom(name); if (room != null) { String reservedNick = room.getReservedNickname(senderJID); if (reservedNick != null) { Element identity = DocumentHelper.createElement("identity"); identity.addAttribute("category", "conference"); identity.addAttribute("name", reservedNick); identity.addAttribute("type", "text"); identities.add(identity); } } } return identities.iterator(); } @Override public Iterator<String> getFeatures(String name, String node, JID senderJID) { ArrayList<String> features = new ArrayList<>(); if (name == null && node == null) { // Answer the features of the MUC service features.add("http://jabber.org/protocol/muc"); features.add("http://jabber.org/protocol/disco#info"); features.add("http://jabber.org/protocol/disco#items"); features.add("jabber:iq:search"); features.add(ResultSet.NAMESPACE_RESULT_SET_MANAGEMENT); if (!extraDiscoFeatures.isEmpty()) { features.addAll(extraDiscoFeatures); } } else if (name != null && node == null) { // Answer the features of a given room MUCRoom room = getChatRoom(name); if (room != null) { features.add("http://jabber.org/protocol/muc"); if (room.isPublicRoom()) { features.add("muc_public"); } else { features.add("muc_hidden"); } if (room.isMembersOnly()) { features.add("muc_membersonly"); } else { features.add("muc_open"); } if (room.isModerated()) { features.add("muc_moderated"); } else { features.add("muc_unmoderated"); } if (room.canAnyoneDiscoverJID()) { features.add("muc_nonanonymous"); } else { features.add("muc_semianonymous"); } if (room.isPasswordProtected()) { features.add("muc_passwordprotected"); } else { features.add("muc_unsecured"); } if (room.isPersistent()) { features.add("muc_persistent"); } else { features.add("muc_temporary"); } if (!extraDiscoFeatures.isEmpty()) { features.addAll(extraDiscoFeatures); } } } return features.iterator(); } @Override public DataForm getExtendedInfo(String name, String node, JID senderJID) { if (name != null && node == null) { // Answer the extended info of a given room MUCRoom room = getChatRoom(name); if (room != null) { final DataForm dataForm = new DataForm(Type.result); final FormField fieldType = dataForm.addField(); fieldType.setVariable("FORM_TYPE"); fieldType.setType(FormField.Type.hidden); fieldType.addValue("http://jabber.org/protocol/muc#roominfo"); final FormField fieldDescr = dataForm.addField(); fieldDescr.setVariable("muc#roominfo_description"); fieldDescr.setLabel(LocaleUtils.getLocalizedString("muc.extended.info.desc")); fieldDescr.addValue(room.getDescription()); final FormField fieldSubj = dataForm.addField(); fieldSubj.setVariable("muc#roominfo_subject"); fieldSubj.setLabel(LocaleUtils.getLocalizedString("muc.extended.info.subject")); fieldSubj.addValue(room.getSubject()); final FormField fieldOcc = dataForm.addField(); fieldOcc.setVariable("muc#roominfo_occupants"); fieldOcc.setLabel(LocaleUtils.getLocalizedString("muc.extended.info.occupants")); fieldOcc.addValue(Integer.toString(room.getOccupantsCount())); /*field = new XFormFieldImpl("muc#roominfo_lang"); field.setLabel(LocaleUtils.getLocalizedString("muc.extended.info.language")); field.addValue(room.getLanguage()); dataForm.addField(field);*/ final FormField fieldDate = dataForm.addField(); fieldDate.setVariable("x-muc#roominfo_creationdate"); fieldDate.setLabel(LocaleUtils.getLocalizedString("muc.extended.info.creationdate")); fieldDate.addValue(XMPPDateTimeFormat.format(room.getCreationDate())); return dataForm; } } return null; } /** * Adds an extra Disco feature to the list of features returned for the conference service. * @param feature Feature to add. */ public void addExtraFeature(String feature) { extraDiscoFeatures.add(feature); } /** * Removes an extra Disco feature from the list of features returned for the conference service. * @param feature Feature to remove. */ public void removeExtraFeature(String feature) { extraDiscoFeatures.remove(feature); } /** * Adds an extra Disco identity to the list of identities returned for the conference service. * @param category Category for identity. e.g. conference * @param name Descriptive name for identity. e.g. Public Chatrooms * @param type Type for identity. e.g. text */ public void addExtraIdentity(String category, String name, String type) { Element identity = DocumentHelper.createElement("identity"); identity.addAttribute("category", category); identity.addAttribute("name", name); identity.addAttribute("type", type); extraDiscoIdentities.add(identity); } /** * Removes an extra Disco identity from the list of identities returned for the conference service. * @param name Name of identity to remove. */ public void removeExtraIdentity(String name) { final Iterator<Element> iter = extraDiscoIdentities.iterator(); while (iter.hasNext()) { Element elem = iter.next(); if (name.equals(elem.attribute("name").getStringValue())) { iter.remove(); break; } } } /** * Sets the MUC event delegate handler for this service. * @param delegate Handler for MUC events. */ public void setMUCDelegate(MUCEventDelegate delegate) { mucEventDelegate = delegate; } /** * Gets the MUC event delegate handler for this service. * @return Handler for MUC events (delegate) */ public MUCEventDelegate getMUCDelegate() { return mucEventDelegate; } @Override public boolean hasInfo(String name, String node, JID senderJID) { // Check if the service is disabled. Info is not available when disabled. if (!isServiceEnabled()) { return false; } if (name == null && node == null) { // We always have info about the MUC service return true; } else if (name != null && node == null) { // We only have info if the room exists return hasChatRoom(name); } else if (name != null && "x-roomuser-item".equals(node)) { // We always have info about reserved names as long as the room exists return hasChatRoom(name); } return false; } @Override public Iterator<DiscoItem> getItems(String name, String node, JID senderJID) { // Check if the service is disabled. Info is not available when disabled. if (!isServiceEnabled()) { return null; } Set<DiscoItem> answer = new HashSet<>(); if (name == null && node == null) { // Answer all the public rooms as items for (MUCRoom room : localMUCRoomManager.getRooms()) { if (canDiscoverRoom(room, senderJID)) { answer.add(new DiscoItem(room.getRole().getRoleAddress(), room.getNaturalLanguageName(), null, null)); } } } else if (name != null && node == null) { // Answer the room occupants as items if that info is publicly available MUCRoom room = getChatRoom(name); if (room != null && canDiscoverRoom(room, senderJID)) { for (MUCRole role : room.getOccupants()) { // TODO Should we filter occupants that are invisible (presence is not broadcasted)? answer.add(new DiscoItem(role.getRoleAddress(), null, null, null)); } } } return answer.iterator(); } private boolean canDiscoverRoom(MUCRoom room, JID senderJID) { // Check if locked rooms may be discovered if (!allowToDiscoverLockedRooms && room.isLocked()) { return false; } if (!room.isPublicRoom()) { if (!allowToDiscoverMembersOnlyRooms && room.isMembersOnly()) { return false; } MUCRole.Affiliation affiliation = room.getAffiliation(senderJID.asBareJID()); if (affiliation != MUCRole.Affiliation.owner && affiliation != MUCRole.Affiliation.admin && affiliation != MUCRole.Affiliation.member) { return false; } } return true; } /** * Converts an array to a comma-delimited String. * * @param array the array. * @return a comma delimited String of the array values. */ private static String fromArray(String [] array) { StringBuilder buf = new StringBuilder(); for (int i=0; i<array.length; i++) { buf.append(array[i]); if (i != array.length-1) { buf.append(','); } } return buf.toString(); } /** * Converts a collection to a comma-delimited String. * * @param coll the collection. * @return a comma delimited String of the array values. */ private static String fromCollection(Collection<JID> coll) { StringBuilder buf = new StringBuilder(); for (JID elem: coll) { buf.append(elem.toBareJID()).append(','); } int endPos = buf.length() > 1 ? buf.length() - 1 : 0; return buf.substring(0, endPos); } @Override public boolean isHidden() { return isHidden; } }