/* * Copyright (C) 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.archive; import java.io.Externalizable; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.jivesoftware.database.DbConnectionManager; import org.jivesoftware.database.JiveID; import org.jivesoftware.database.SequenceManager; import org.jivesoftware.openfire.XMPPServer; import org.jivesoftware.openfire.muc.MUCRole; import org.jivesoftware.openfire.muc.MUCRoom; import org.jivesoftware.openfire.plugin.MonitoringPlugin; import org.jivesoftware.openfire.user.UserNameManager; import org.jivesoftware.openfire.user.UserNotFoundException; import org.jivesoftware.util.JiveGlobals; import org.jivesoftware.util.LocaleUtils; import org.jivesoftware.util.NotFoundException; import org.jivesoftware.util.StringUtils; import org.jivesoftware.util.cache.ExternalizableUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmpp.packet.JID; /** * Represents an IM conversation between two people. A conversation encompasses a series of messages sent back and forth. It may cover a single topic * or several. The start of a conversation occurs when the first message between two users is sent. It ends when either: * <ul> * <li>No messages are sent between the users for a certain period of time (default of 10 minutes). The default value can be overridden by setting the * Openfire property <tt>conversation.idleTime</tt>.</li> * <li>The total conversation time reaches a maximum value (default of 60 minutes). The default value can be overridden by setting the Openfire * property <tt>conversation.maxTime</tt>. When the max time has been reached and additional messages are sent between the users, a new conversation * will simply be started.</li> * </ul> * <p/> * Each conversation has a start time, date of the last message, and count of the messages in the conversation. Conversations are specially marked if * one of the participants is on an external server. If archiving is enabled, the actual messages in the conversation can be retrieved. * * @author Matt Tucker */ @JiveID(50) public class Conversation implements Externalizable { private static final Logger Log = LoggerFactory.getLogger(Conversation.class); private static final String INSERT_CONVERSATION = "INSERT INTO ofConversation(conversationID, room, isExternal, startDate, " + "lastActivity, messageCount) VALUES (?,?,?,?,?,0)"; private static final String INSERT_PARTICIPANT = "INSERT INTO ofConParticipant(conversationID, joinedDate, bareJID, jidResource, nickname) " + "VALUES (?,?,?,?,?)"; private static final String LOAD_CONVERSATION = "SELECT room, isExternal, startDate, lastActivity, messageCount " + "FROM ofConversation WHERE conversationID=?"; private static final String LOAD_PARTICIPANTS = "SELECT bareJID, jidResource, nickname, joinedDate, leftDate FROM ofConParticipant " + "WHERE conversationID=? ORDER BY joinedDate"; private static final String LOAD_MESSAGES = "SELECT fromJID, fromJIDResource, toJID, toJIDResource, sentDate, body FROM ofMessageArchive WHERE conversationID=? " + "ORDER BY sentDate"; private transient ConversationManager conversationManager; private long conversationID = -1; private Map<String, UserParticipations> participants; private boolean external; private Date startDate; private Date lastActivity; private int messageCount; /** * Room where the group conversion is taking place. For one-to-one chats there is no room so this variable will be null. */ private JID room; /** * Do not use this constructor. It only exists for serialization purposes. */ public Conversation() { } /** * Constructs a new one-to-one conversation. * * @param conversationManager * the ConversationManager. * @param users * the two participants in the conversation. * @param external * true if the conversation includes a user on another server. * @param startDate * the starting date of the conversation. */ public Conversation(ConversationManager conversationManager, Collection<JID> users, boolean external, Date startDate) { if (users.size() != 2) { throw new IllegalArgumentException("Illegal number of participants: " + users.size()); } this.conversationManager = conversationManager; this.participants = new HashMap<String, UserParticipations>(2); // Ensure that we're use the full JID of each participant. for (JID user : users) { UserParticipations userParticipations = new UserParticipations(false); userParticipations.addParticipation(new ConversationParticipation(startDate)); participants.put(user.toString(), userParticipations); } this.external = external; this.startDate = startDate; this.lastActivity = startDate; // If archiving is enabled, insert the conversation into the database. if (conversationManager.isMetadataArchivingEnabled()) { try { insertIntoDb(); } catch (Exception e) { Log.error(e.getMessage(), e); } } } /** * Constructs a new group chat conversation that is taking place in a room. * * @param conversationManager * the ConversationManager. * @param room * the JID of the room where the conversation is taking place. * @param external * true if the conversation includes a user on another server. * @param startDate * the starting date of the conversation. */ public Conversation(ConversationManager conversationManager, JID room, boolean external, Date startDate) { this.conversationManager = conversationManager; this.participants = new ConcurrentHashMap<String, UserParticipations>(); // Add list of existing room occupants as participants of this conversation MUCRoom mucRoom = XMPPServer.getInstance().getMultiUserChatManager().getMultiUserChatService(room).getChatRoom(room.getNode()); if (mucRoom != null) { for (MUCRole role : mucRoom.getOccupants()) { UserParticipations userParticipations = new UserParticipations(true); userParticipations.addParticipation(new ConversationParticipation(startDate, role.getNickname())); participants.put(role.getUserAddress().toString(), userParticipations); } } this.room = room; this.external = external; this.startDate = startDate; this.lastActivity = startDate; // If archiving is enabled, insert the conversation into the database. if (conversationManager.isMetadataArchivingEnabled()) { try { insertIntoDb(); } catch (Exception e) { Log.error(e.getMessage(), e); } } } /** * Loads a conversation from the database. * * @param conversationManager * the conversation manager. * @param conversationID * the ID of the conversation. * @throws NotFoundException * if the conversation can't be loaded. */ public Conversation(ConversationManager conversationManager, long conversationID) throws NotFoundException { this.conversationManager = conversationManager; this.conversationID = conversationID; loadFromDb(); } /** * Returns the unique ID of the conversation. A unique ID is only meaningful when conversation archiving is enabled. Therefore, this method * returns <tt>-1</tt> if archiving is not turned on. * * @return the unique ID of the conversation, or <tt>-1</tt> if conversation archiving is not enabled. */ public long getConversationID() { return conversationID; } /** * Returns the JID of the room where the group conversation took place. If the conversation was a one-to-one chat then a <tt>null</tt> value is * returned. * * @return the JID of room or null if this was a one-to-one chat. */ public JID getRoom() { return room; } /** * Returns the conversation participants. * * @return the two conversation participants. Returned JIDs are full JIDs. */ public Collection<JID> getParticipants() { List<JID> users = new ArrayList<JID>(); for (String key : participants.keySet()) { users.add(new JID(key)); } return users; } /** * Returns the participations of the specified user (full JID) in this conversation. Each participation will hold the time when the user joined * and left the conversation and the nickname if the room happened in a room. * * @param user * the full JID of the user. * @return the participations of the specified user (full JID) in this conversation. */ public Collection<ConversationParticipation> getParticipations(JID user) { UserParticipations userParticipations = participants.get(user.toString()); if (userParticipations == null) { return Collections.emptyList(); } return userParticipations.getParticipations(); } /** * Returns true if one of the conversation participants is on an external server. * * @return true if one of the conversation participants is on an external server. */ public boolean isExternal() { return external; } /** * Returns the starting timestamp of the conversation. * * @return the start date. */ public Date getStartDate() { return startDate; } /** * Returns the timestamp the last message was receieved. * * @return the last activity. */ public Date getLastActivity() { return lastActivity; } /** * Returns the number of messages that make up the conversation. * * @return the message count. */ public int getMessageCount() { return messageCount; } /** * Returns the archived messages in the conversation. If message archiving is not enabled, this method will always return an empty collection. * This method will only return messages that have already been batch-archived to the database; in other words, it does not provide a real-time * view of new messages. * * @return the archived messages in the conversation. */ public List<ArchivedMessage> getMessages() { if (room == null && !conversationManager.isMessageArchivingEnabled()) { return Collections.emptyList(); } else if (room != null && !conversationManager.isRoomArchivingEnabled()) { return Collections.emptyList(); } List<ArchivedMessage> messages = new ArrayList<ArchivedMessage>(); Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = DbConnectionManager.getConnection(); pstmt = con.prepareStatement(LOAD_MESSAGES); pstmt.setLong(1, getConversationID()); rs = pstmt.executeQuery(); while (rs.next()) { JID fromJID = new JID(rs.getString(1)); String fromJIDResource = rs.getString(2); if (fromJIDResource != null && !"".equals(fromJIDResource)) { fromJID = new JID(rs.getString(1) + "/" + fromJIDResource); } JID toJID = new JID(rs.getString(3)); String toJIDResource = rs.getString(4); if (toJIDResource != null && !"".equals(toJIDResource)) { toJID = new JID(rs.getString(3) + "/" + toJIDResource); } Date date = new Date(rs.getLong(5)); String body = DbConnectionManager.getLargeTextField(rs, 6); messages.add(new ArchivedMessage(conversationID, fromJID, toJID, date, body, false)); } } catch (SQLException sqle) { Log.error(sqle.getMessage(), sqle); } finally { DbConnectionManager.closeConnection(rs, pstmt, con); } // Add messages of users joining or leaving the group chat conversation if (room != null) { for (Map.Entry<String, UserParticipations> entry : participants.entrySet()) { JID user = new JID(entry.getKey()); boolean anonymous = false; String name; try { name = UserNameManager.getUserName(user); } catch (UserNotFoundException e) { name = user.toBareJID(); anonymous = true; } for (ConversationParticipation participation : entry.getValue().getParticipations()) { if (participation.getJoined() == null) { Log.warn("Found muc participant with no join date in conversation: " + conversationID); continue; } JID jid = new JID(room + "/" + participation.getNickname()); String joinBody; String leftBody; if (anonymous) { joinBody = LocaleUtils.getLocalizedString("muc.conversation.joined.anonymous", MonitoringConstants.NAME, Arrays.asList(participation.getNickname())); leftBody = LocaleUtils.getLocalizedString("muc.conversation.left.anonymous", MonitoringConstants.NAME, Arrays.asList(participation.getNickname())); } else { joinBody = LocaleUtils.getLocalizedString("muc.conversation.joined", MonitoringConstants.NAME, Arrays.asList(participation.getNickname(), name)); leftBody = LocaleUtils.getLocalizedString("muc.conversation.left", MonitoringConstants.NAME, Arrays.asList(participation.getNickname(), name)); } messages.add(new ArchivedMessage(conversationID, user, jid, participation.getJoined(), joinBody, true)); if (participation.getLeft() != null) { messages.add(new ArchivedMessage(conversationID, user, jid, participation.getLeft(), leftBody, true)); } } } // Sort messages by sent date Collections.sort(messages, new Comparator<ArchivedMessage>() { public int compare(ArchivedMessage o1, ArchivedMessage o2) { return o1.getSentDate().compareTo(o2.getSentDate()); } }); } return messages; } @Override public String toString() { StringBuilder buf = new StringBuilder(); buf.append("Conversation [").append(conversationID).append("]"); if (room != null) { buf.append(" in room").append(room); } buf.append(" between ").append(participants); buf.append(". started ").append(JiveGlobals.formatDateTime(startDate)); buf.append(", last active ").append(JiveGlobals.formatDateTime(lastActivity)); buf.append(". Total messages: ").append(messageCount); return buf.toString(); } /** * Called when a new message for the conversation is received. Each time a new message is received, the last activity date will be updated and the * message count incremented. * * @param entity * JID of the entity that sent the message. * @param date * the date the message was sent. */ synchronized void messageReceived(JID entity, Date date) { lastActivity = date; messageCount++; } synchronized void participantJoined(JID user, String nickname, long timestamp) { // Add the sender of the message as a participant of this conversation. If the sender // was already a participant then he/she will appear just once. Rooms are never considered // as participants UserParticipations userParticipations = participants.get(user.toString()); if (userParticipations == null) { userParticipations = new UserParticipations(true); participants.put(user.toString(), userParticipations); } else { // Get last known participation and check that the user has finished it ConversationParticipation lastParticipation = userParticipations.getRecentParticipation(); if (lastParticipation != null && lastParticipation.getLeft() == null) { Log.warn("Found user that never left a previous conversation: " + user); lastParticipation.participationEnded(new Date(timestamp)); // Queue storeage of updated participation information conversationManager.queueParticipantLeft(this, user, lastParticipation); } } ConversationParticipation newParticipation = new ConversationParticipation(new Date(timestamp), nickname); // Add element to the beginning of the list userParticipations.addParticipation(newParticipation); // If archiving is enabled, insert the conversation into the database (if not persistent yet). if (conversationManager.isMetadataArchivingEnabled()) { try { if (conversationID == -1) { // Save new conversation to the database insertIntoDb(); } else { // Store new participation information insertIntoDb(user, nickname, timestamp); } } catch (Exception e) { Log.error(e.getMessage(), e); } } } synchronized void participantLeft(JID user, long timestamp) { // Get the list of participations of the specified user UserParticipations userParticipations = participants.get(user.toString()); if (userParticipations == null) { Log.warn("Found user that left a conversation but never started it: " + user); } else { // Get last known participation and check that the user has not finished it ConversationParticipation currentParticipation = userParticipations.getRecentParticipation(); if (currentParticipation == null || currentParticipation.getLeft() != null) { Log.warn("Found user that left a conversation but never started it: " + user); } else { currentParticipation.participationEnded(new Date(timestamp)); // Queue storeage of updated participation information conversationManager.queueParticipantLeft(this, user, currentParticipation); } } } /** * Inserts a new conversation into the database. * * @throws SQLException * if an error occurs inserting the conversation. */ private void insertIntoDb() throws SQLException { this.conversationID = SequenceManager.nextID(this); Connection con = null; boolean abortTransaction = false; try { con = DbConnectionManager.getTransactionConnection(); PreparedStatement pstmt = con.prepareStatement(INSERT_CONVERSATION); pstmt.setLong(1, conversationID); pstmt.setString(2, room == null ? null : room.toString()); pstmt.setInt(3, (external ? 1 : 0)); pstmt.setLong(4, startDate.getTime()); pstmt.setLong(5, lastActivity.getTime()); pstmt.executeUpdate(); pstmt.close(); pstmt = con.prepareStatement(INSERT_PARTICIPANT); for (Map.Entry<String, UserParticipations> entry : participants.entrySet()) { JID user = new JID(entry.getKey()); for (ConversationParticipation participation : entry.getValue().getParticipations()) { pstmt.setLong(1, conversationID); pstmt.setLong(2, participation.getJoined().getTime()); pstmt.setString(3, user.toBareJID()); pstmt.setString(4, user.getResource() == null ? "" : user.getResource()); pstmt.setString(5, participation.getNickname()); pstmt.executeUpdate(); } } pstmt.close(); } catch (SQLException sqle) { abortTransaction = true; throw sqle; } finally { DbConnectionManager.closeTransactionConnection(con, abortTransaction); } } /** * Adds a new conversation participant into the database. * * @param participant * the full JID of the participant. * @param nickname * nickname of the user in the room. * @param joined * timestamp when user joined the conversation. * @throws SQLException * if an error occurs inserting the conversation. */ private void insertIntoDb(JID participant, String nickname, long joined) throws SQLException { Connection con = null; try { con = DbConnectionManager.getConnection(); PreparedStatement pstmt = con.prepareStatement(INSERT_PARTICIPANT); pstmt.setLong(1, conversationID); pstmt.setLong(2, joined); pstmt.setString(3, participant.toBareJID()); pstmt.setString(4, participant.getResource() == null ? "" : participant.getResource()); pstmt.setString(5, nickname); pstmt.executeUpdate(); pstmt.close(); } catch (SQLException sqle) { throw sqle; } finally { DbConnectionManager.closeConnection(con); } } private void loadFromDb() throws NotFoundException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = DbConnectionManager.getConnection(); pstmt = con.prepareStatement(LOAD_CONVERSATION); pstmt.setLong(1, conversationID); rs = pstmt.executeQuery(); if (!rs.next()) { throw new NotFoundException("Conversation not found: " + conversationID); } this.room = rs.getString(1) == null ? null : new JID(rs.getString(1)); this.external = rs.getInt(2) == 1; this.startDate = new Date(rs.getLong(3)); this.lastActivity = new Date(rs.getLong(4)); this.messageCount = rs.getInt(5); rs.close(); pstmt.close(); this.participants = new ConcurrentHashMap<String, UserParticipations>(); pstmt = con.prepareStatement(LOAD_PARTICIPANTS); pstmt.setLong(1, conversationID); rs = pstmt.executeQuery(); while (rs.next()) { // Rebuild full JID of participant String baredJID = rs.getString(1); String resource = rs.getString(2); JID fullJID = new JID("".equals(resource) ? baredJID : baredJID + "/" + resource); // Rebuild joined and left time ConversationParticipation participation = new ConversationParticipation(new Date(rs.getLong(4)), rs.getString(3)); if (rs.getLong(5) > 0) { participation.participationEnded(new Date(rs.getLong(5))); } // Store participation data UserParticipations userParticipations = participants.get(fullJID.toString()); if (userParticipations == null) { userParticipations = new UserParticipations(room != null); participants.put(fullJID.toString(), userParticipations); } userParticipations.addParticipation(participation); } } catch (SQLException sqle) { Log.error(sqle.getMessage(), sqle); } finally { DbConnectionManager.closeConnection(rs, pstmt, con); } } /** * Notification message inficating that conversation has finished so remaining participants should be marked that they left the conversation. * * @param nowDate * the date when the conversation was finished */ void conversationEnded(Date nowDate) { for (Map.Entry<String, UserParticipations> entry : participants.entrySet()) { ConversationParticipation currentParticipation = entry.getValue().getRecentParticipation(); if (currentParticipation.getLeft() == null) { currentParticipation.participationEnded(nowDate); // Queue storage of updated participation information conversationManager.queueParticipantLeft(this, new JID(entry.getKey()), currentParticipation); } } } public void writeExternal(ObjectOutput out) throws IOException { ExternalizableUtil.getInstance().writeLong(out, conversationID); ExternalizableUtil.getInstance().writeExternalizableMap(out, participants); ExternalizableUtil.getInstance().writeBoolean(out, external); ExternalizableUtil.getInstance().writeLong(out, startDate.getTime()); ExternalizableUtil.getInstance().writeLong(out, lastActivity.getTime()); ExternalizableUtil.getInstance().writeInt(out, messageCount); ExternalizableUtil.getInstance().writeBoolean(out, room != null); if (room != null) { ExternalizableUtil.getInstance().writeSerializable(out, room); } } public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { MonitoringPlugin plugin = (MonitoringPlugin) XMPPServer.getInstance().getPluginManager().getPlugin(MonitoringConstants.NAME); conversationManager = (ConversationManager) plugin.getModule(ConversationManager.class); this.participants = new ConcurrentHashMap<String, UserParticipations>(); conversationID = ExternalizableUtil.getInstance().readLong(in); ExternalizableUtil.getInstance().readExternalizableMap(in, participants, getClass().getClassLoader()); external = ExternalizableUtil.getInstance().readBoolean(in); startDate = new Date(ExternalizableUtil.getInstance().readLong(in)); lastActivity = new Date(ExternalizableUtil.getInstance().readLong(in)); messageCount = ExternalizableUtil.getInstance().readInt(in); if (ExternalizableUtil.getInstance().readBoolean(in)) { room = (JID) ExternalizableUtil.getInstance().readSerializable(in); } } }