/* * Copyright (c) 2006-2015 DMDirc Developers * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ package com.dmdirc.parser.xmpp; import com.dmdirc.parser.common.AwayState; import com.dmdirc.parser.common.BaseSocketAwareParser; import com.dmdirc.parser.common.ChannelJoinRequest; import com.dmdirc.parser.common.ChildImplementations; import com.dmdirc.parser.common.CompositionState; import com.dmdirc.parser.common.DefaultStringConverter; import com.dmdirc.parser.common.ParserError; import com.dmdirc.parser.common.QueuePriority; import com.dmdirc.parser.interfaces.ChannelInfo; import com.dmdirc.parser.interfaces.ClientInfo; import com.dmdirc.parser.interfaces.LocalClientInfo; import com.dmdirc.parser.interfaces.StringConverter; import com.dmdirc.parser.interfaces.callbacks.AwayStateListener; import com.dmdirc.parser.interfaces.callbacks.CallbackInterface; import com.dmdirc.parser.interfaces.callbacks.ChannelSelfJoinListener; import com.dmdirc.parser.interfaces.callbacks.CompositionStateChangeListener; import com.dmdirc.parser.interfaces.callbacks.ConnectErrorListener; import com.dmdirc.parser.interfaces.callbacks.DataInListener; import com.dmdirc.parser.interfaces.callbacks.DataOutListener; import com.dmdirc.parser.interfaces.callbacks.NumericListener; import com.dmdirc.parser.interfaces.callbacks.OtherAwayStateListener; import com.dmdirc.parser.interfaces.callbacks.PrivateActionListener; import com.dmdirc.parser.interfaces.callbacks.PrivateMessageListener; import com.dmdirc.parser.interfaces.callbacks.ServerReadyListener; import com.dmdirc.parser.interfaces.callbacks.SocketCloseListener; import java.net.URI; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jivesoftware.smack.Chat; import org.jivesoftware.smack.ChatManagerListener; import org.jivesoftware.smack.ConnectionConfiguration; import org.jivesoftware.smack.ConnectionListener; import org.jivesoftware.smack.PacketListener; import org.jivesoftware.smack.RosterEntry; import org.jivesoftware.smack.RosterListener; import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.filter.PacketFilter; import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.packet.Packet; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smackx.ChatState; import org.jivesoftware.smackx.ChatStateListener; import org.jivesoftware.smackx.ChatStateManager; import org.jivesoftware.smackx.muc.MultiUserChat; import org.slf4j.LoggerFactory; /** * A parser which can understand the XMPP protocol. */ @ChildImplementations({ XmppClientInfo.class, XmppLocalClientInfo.class, XmppFakeChannel.class, XmppChannelClientInfo.class }) public class XmppParser extends BaseSocketAwareParser { private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(XmppParser.class); /** Pattern to use to extract priority. */ private static final Pattern PRIORITY_PATTERN = Pattern.compile( "(?i)(?:^|&)priority=([0-9]+)(?:$|&)"); /** The connection to use. */ private XMPPConnection connection; /** The state manager for the current connection. */ private ChatStateManager stateManager; /** A cache of known chats. */ private final Map chats = new HashMap<>(); /** A cache of known clients. */ private final Map contacts = new HashMap<>(); /** Whether or not to use a fake local channel for a buddy list replacement. */ private final boolean useFakeChannel; /** The priority of this endpoint. */ private final int priority; /** The fake channel to use is useFakeChannel is enabled. */ private XmppFakeChannel fakeChannel; /** * Creates a new XMPP parser for the specified address. * * @param address The address to connect to */ public XmppParser(final URI address) { super(address); if (address.getQuery() == null) { useFakeChannel = false; priority = 0; } else { final Matcher matcher = PRIORITY_PATTERN.matcher(address.getQuery()); useFakeChannel = address.getQuery().matches("(?i).*(^|&)showchannel($|&).*"); priority = matcher.find() ? Integer.parseInt(matcher.group(1)) : 0; } LOG.debug( "XMPP parser created with query string {}, parsed fake channel = {}, priority = {}", address.getQuery(), useFakeChannel, priority); } @Override public void disconnect(final String message) { super.disconnect(message); // TODO: Pass quit message on as presence? connection.disconnect(); } @Override public void joinChannels(final ChannelJoinRequest... channels) { for (ChannelJoinRequest request : channels) { final MultiUserChat muc = new MultiUserChat(connection, request.getName().substring(1)); try { if (request.getPassword() == null) { muc.join(getLocalClient().getNickname()); } else { muc.join(getLocalClient().getNickname(), request.getPassword()); } // TODO: Send callbacks etc } catch (XMPPException ex) { // TODO: handle } } } @Override public ChannelInfo getChannel(final String channel) { // TODO: Implement throw new UnsupportedOperationException("Not supported yet."); } @Override public Collection getChannels() { return Collections.emptyList(); } @Override public int getMaxLength(final String type, final String target) { return Integer.MAX_VALUE; } @Override public int getMaxLength() { return Integer.MAX_VALUE; } @Override public LocalClientInfo getLocalClient() { final String[] parts = parseHostmask(connection.getUser()); // TODO: Cache this return new XmppLocalClientInfo(this, parts[0], parts[2], parts[1]); } @Override public XmppClientInfo getClient(final String details) { final String[] parts = parseHostmask(details); if (!contacts.containsKey(parts[0])) { contacts.put(parts[0], new XmppClientInfo(this, parts[0], parts[2], parts[1])); } return contacts.get(parts[0]); } @Override public void sendRawMessage(final String message) { // Urgh, hacky horrible rubbish. These commands should call methods. if (message.toUpperCase().startsWith("WHOIS ")) { handleWhois(message.split(" ")[1]); } else if (!message.isEmpty() && message.charAt(0) == '<') { // Looks vaguely like XML, let's send it. connection.sendPacket(new Packet() { @Override public String toXML() { return message; } }); } } /** * Handles a whois request for the specified target. * * @param target The user being WHOIS'd */ private void handleWhois(final String target) { // Urgh, hacky horrible rubbish. This should be abstracted. if (contacts.containsKey(target)) { final XmppClientInfo client = contacts.get(target); final String[] userParts = client.getNickname().split("@", 2); callNumericCallback(311, target, userParts[0], userParts[1], "*", client.getRealname()); for (Map.Entry endpoint : client.getEndpoints().entrySet()) { callNumericCallback(399, target, endpoint.getKey(), "(" + endpoint.getValue().getPresence() + ")", "has endpoint"); } } else { callNumericCallback(401, target, "No such contact found"); } callNumericCallback(318, target, "End of /WHOIS."); } private void callNumericCallback(final int numeric, final String... args) { final String[] newArgs = new String[args.length + 3]; newArgs[0] = ":xmpp.server"; newArgs[1] = (numeric < 100 ? "0" : "") + (numeric < 10 ? "0" : "") + numeric; newArgs[2] = getLocalClient().getNickname(); System.arraycopy(args, 0, newArgs, 3, args.length); getCallback(NumericListener.class).onNumeric(this, new Date(), numeric, newArgs); } @Override public void sendRawMessage(final String message, final QueuePriority priority) { sendRawMessage(message); } @Override public StringConverter getStringConverter() { return new DefaultStringConverter(); } @Override public boolean isValidChannelName(final String name) { return false; // TODO: Implement } @Override public boolean compareURI(final URI uri) { throw new UnsupportedOperationException("Not supported yet."); } @Override public Collection extractChannels(final URI uri) { return Collections.emptyList(); } @Override public String getNetworkName() { return "XMPP"; // TODO } @Override public String getServerSoftware() { return "Unknown"; // TODO } @Override public String getServerSoftwareType() { return "XMPP"; // TODO } @Override public List getServerInformationLines() { return Collections.emptyList(); // TODO } @Override public int getMaxTopicLength() { return 0; // TODO } @Override public String getBooleanChannelModes() { return ""; // TODO } @Override public String getListChannelModes() { return ""; // TODO } @Override public int getMaxListModes(final char mode) { return 0; // TODO } @Override public boolean isUserSettable(final char mode) { throw new UnsupportedOperationException("Not supported yet."); } @Override public String getParameterChannelModes() { return ""; // TODO } @Override public String getDoubleParameterChannelModes() { return ""; // TODO } @Override public String getUserModes() { return ""; // TODO } @Override public String getChannelUserModes() { return ""; // TODO } @Override public String getChannelPrefixes() { return "#"; } @Override public long getServerLatency() { return 1000L; // TODO } @Override public void sendCTCP(final String target, final String type, final String message) { throw new UnsupportedOperationException("Not supported yet."); } @Override public void sendCTCPReply(final String target, final String type, final String message) { throw new UnsupportedOperationException("Not supported yet."); } @Override public void sendMessage(final String target, final String message) { if (!chats.containsKey(target)) { LOG.debug("Creating new chat for {}", target); chats.put(target, connection.getChatManager().createChat(target, new MessageListenerImpl())); } try { chats.get(target).sendMessage(message); } catch (XMPPException ex) { // TODO: Handle this } } @Override public void sendNotice(final String target, final String message) { throw new UnsupportedOperationException("Not supported yet."); } @Override public void sendAction(final String target, final String message) { sendMessage(target, "/me " + message); } @Override public void sendInvite(final String channel, final String user) { throw new UnsupportedOperationException("Not supported yet."); } @Override public void sendWhois(final String nickname) { // TODO: Implement this } @Override public String getLastLine() { return "TODO: Implement me"; } @Override public String[] parseHostmask(final String hostmask) { return new XmppProtocolDescription().parseHostmask(hostmask); } @Override public long getPingTime() { throw new UnsupportedOperationException("Not supported yet."); } @Override public void run() { if (getURI().getUserInfo() == null || !getURI().getUserInfo().contains(":")) { getCallback(ConnectErrorListener.class).onConnectError(this, new Date(), new ParserError(ParserError.ERROR_USER, "User name and password must be specified in URI", "")); return; } final String[] userInfoParts = getURI().getUserInfo().split(":", 2); final String[] userParts = userInfoParts[0].split("@", 2); final ConnectionConfiguration config = new ConnectionConfiguration(getURI().getHost(), getURI().getPort(), userParts[0]); config.setSecurityMode(getURI().getScheme().equalsIgnoreCase("xmpps") ? ConnectionConfiguration.SecurityMode.required : ConnectionConfiguration.SecurityMode.disabled); config.setSASLAuthenticationEnabled(true); config.setReconnectionAllowed(false); config.setRosterLoadedAtLogin(true); config.setSocketFactory(getSocketFactory()); connection = new FixedXmppConnection(config); try { connection.connect(); connection.addConnectionListener(new ConnectionListenerImpl()); connection.addPacketListener(new PacketListenerImpl(DataInListener.class), new AcceptAllPacketFilter()); connection.addPacketSendingListener(new PacketListenerImpl(DataOutListener.class), new AcceptAllPacketFilter()); connection.getChatManager().addChatListener(new ChatManagerListenerImpl()); try { connection.login(userInfoParts[0], userInfoParts[1], "DMDirc."); } catch (XMPPException ex) { getCallback(ConnectErrorListener.class).onConnectError(this, new Date(), new ParserError(ParserError.ERROR_USER, ex.getMessage(), "")); return; } connection.sendPacket(new Presence(Presence.Type.available, null, priority, Presence.Mode.available)); connection.getRoster().addRosterListener(new RosterListenerImpl()); stateManager = ChatStateManager.getInstance(connection); setServerName(connection.getServiceName()); getCallback(ServerReadyListener.class).onServerReady(this, new Date()); for (RosterEntry contact : connection.getRoster().getEntries()) { getClient(contact.getUser()).setRosterEntry(contact); } if (useFakeChannel) { fakeChannel = new XmppFakeChannel(this, "&contacts"); getCallback(ChannelSelfJoinListener.class). onChannelSelfJoin(null, null, fakeChannel); fakeChannel.updateContacts(contacts.values()); contacts.values().stream().filter(XmppClientInfo::isAway).forEach(client -> getCallback(OtherAwayStateListener.class).onAwayStateOther(this, new Date(), client, AwayState.UNKNOWN, AwayState.AWAY)); } } catch (XMPPException ex) { LOG.debug("Go an XMPP exception", ex); connection = null; final ParserError error = new ParserError(ParserError.ERROR_ERROR, "Unable to connect", ""); if (ex.getWrappedThrowable() instanceof Exception) { // Pass along the underlying exception instead of an XMPP // specific one error.setException((Exception) ex.getWrappedThrowable()); } else { error.setException(ex); } getCallback(ConnectErrorListener.class).onConnectError(this, new Date(), error); } } /** * Handles a client's away state changing. * * @param client The client whose state is changing * @param isBack True if the client is coming back, false if they're going away */ public void handleAwayStateChange(final ClientInfo client, final boolean isBack) { LOG.debug("Handling away state change for {} to {}", client.getNickname(), isBack); if (useFakeChannel) { getCallback(OtherAwayStateListener.class) .onAwayStateOther(null, null, client, isBack ? AwayState.AWAY : AwayState.HERE, isBack ? AwayState.HERE : AwayState.AWAY); } } /** * Marks the local user as away with the specified reason. * * @param reason The away reason */ public void setAway(final String reason) { connection.sendPacket(new Presence(Presence.Type.available, reason, priority, Presence.Mode.away)); getCallback(AwayStateListener.class).onAwayState(this, new Date(), AwayState.HERE, AwayState.AWAY, reason); } /** * Marks the local user as back. */ public void setBack() { connection.sendPacket(new Presence(Presence.Type.available, null, priority, Presence.Mode.available)); getCallback(AwayStateListener.class).onAwayState(this, new Date(), AwayState.AWAY, AwayState.HERE, null); } @Override public void setCompositionState(final String host, final CompositionState state) { LOG.debug("Setting composition state for {} to {}", host, state); final Chat chat = chats.get(parseHostmask(host)[0]); final ChatState newState; switch (state) { case ENTERED_TEXT: newState = ChatState.paused; break; case TYPING: newState = ChatState.composing; break; case IDLE: default: newState = ChatState.active; break; } if (chat != null && stateManager != null) { try { stateManager.setCurrentState(newState, chat); } catch (XMPPException ex) { // Can't set chat state... Oh well? LOG.info("Couldn't set composition state", ex); } } } @Override public void requestGroupList(final String searchTerms) { // Do nothing } private class ConnectionListenerImpl implements ConnectionListener { @Override public void connectionClosed() { getCallback(SocketCloseListener.class).onSocketClosed(XmppParser.this, new Date()); } @Override public void connectionClosedOnError(final Exception excptn) { // TODO: Handle exception getCallback(SocketCloseListener.class).onSocketClosed(XmppParser.this, new Date()); } @Override public void reconnectingIn(final int i) { throw new UnsupportedOperationException("Not supported yet."); } @Override public void reconnectionSuccessful() { throw new UnsupportedOperationException("Not supported yet."); } @Override public void reconnectionFailed(final Exception excptn) { throw new UnsupportedOperationException("Not supported yet."); } } private class RosterListenerImpl implements RosterListener { @Override public void entriesAdded(final Collection clctn) { // Do nothing, yet } @Override public void entriesUpdated(final Collection clctn) { // Do nothing, yet } @Override public void entriesDeleted(final Collection clctn) { // Do nothing, yet } @Override public void presenceChanged(final Presence prsnc) { getClient(prsnc.getFrom()).setPresence(prsnc); } } private class ChatManagerListenerImpl implements ChatManagerListener { @Override public void chatCreated(final Chat chat, final boolean bln) { if (!bln) { // Only add chats that weren't created locally chats.put(parseHostmask(chat.getParticipant())[0], chat); chat.addMessageListener(new MessageListenerImpl()); } } } private class MessageListenerImpl implements ChatStateListener { @Override public void processMessage(final Chat chat, final Message msg) { if (msg.getType() == Message.Type.error) { getCallback(NumericListener.class).onNumeric(XmppParser.this, new Date(), 404, new String[]{ ":xmpp", "404", getLocalClient().getNickname(), msg.getFrom(), "Cannot send message: " + msg.getError().toString() }); return; } if (msg.getBody() != null) { if (msg.getBody().startsWith("/me ")) { getCallback(PrivateActionListener.class).onPrivateAction(XmppParser.this, new Date(), msg.getBody().substring(4), msg.getFrom()); } else { getCallback(PrivateMessageListener.class).onPrivateMessage(XmppParser.this, new Date(), msg.getBody(), msg.getFrom()); } } } @Override public void stateChanged(final Chat chat, final ChatState cs) { final CompositionState state; switch (cs) { case paused: state = CompositionState.ENTERED_TEXT; break; case composing: state = CompositionState.TYPING; break; case active: case gone: case inactive: default: state = CompositionState.IDLE; break; } getCallback(CompositionStateChangeListener.class) .onCompositionStateChanged(XmppParser.this, new Date(), state, chat.getParticipant()); } } private class PacketListenerImpl implements PacketListener { private final Class callback; public PacketListenerImpl(final Class callback) { this.callback = callback; } @Override public void processPacket(final Packet packet) { if (callback.equals(DataOutListener.class)) { getCallback(DataOutListener.class).onDataOut(XmppParser.this, new Date(), packet.toXML(), true); } else { getCallback(DataInListener.class).onDataIn(XmppParser.this, new Date(), packet.toXML()); } } } private static class AcceptAllPacketFilter implements PacketFilter { @Override public boolean accept(final Packet packet) { return true; } } }