/*
* FrontlineSMS <http://www.frontlinesms.com>
* Copyright 2007, 2008 kiwanja
*
* This file is part of FrontlineSMS.
*
* FrontlineSMS is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* FrontlineSMS is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
* General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with FrontlineSMS. If not, see <http://www.gnu.org/licenses/>.
*/
package net.frontlinesms.messaging;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import net.frontlinesms.EmailServerHandler;
import net.frontlinesms.FrontlineSMS;
import net.frontlinesms.FrontlineSMSConstants;
import net.frontlinesms.FrontlineUtils;
import net.frontlinesms.XMLReader;
import net.frontlinesms.data.domain.*;
import net.frontlinesms.data.domain.KeywordAction.ExternalCommandResponseActionType;
import net.frontlinesms.data.domain.KeywordAction.ExternalCommandResponseType;
import net.frontlinesms.data.domain.FrontlineMessage.Status;
import net.frontlinesms.data.repository.*;
import net.frontlinesms.data.*;
import net.frontlinesms.listener.IncomingMessageListener;
import net.frontlinesms.listener.UIListener;
import net.frontlinesms.messaging.mms.MmsUtils;
import net.frontlinesms.messaging.sms.SmsService;
import net.frontlinesms.mms.MmsMessage;
import org.apache.log4j.Logger;
import org.jdom.JDOMException;
import org.smslib.CIncomingMessage;
import org.smslib.CStatusReportMessage;
import org.smslib.CMessage.MessageType;
import org.smslib.sms.SmsMessageEncoding;
/**
* Processor of incoming messages for {@link FrontlineSMS}.
* @author Alex
*/
public class IncomingMessageProcessor extends Thread {
/** Time, in millis, thread should sleep for after message processing failed. */
private static final int THREAD_SLEEP_AFTER_PROCESSING_FAILED = 5000;
private static final Logger LOG = FrontlineUtils.getLogger(IncomingMessageProcessor.class);
/** Set hi when the thread should terminate. */
private boolean keepAlive;
/** Queue of messages to process. */
private final BlockingQueue<IncomingMessageProcessorQueueItem> incomingMessageQueue = new LinkedBlockingQueue<IncomingMessageProcessorQueueItem>();
//> DATA ACCESS OBJECTS
private final FrontlineSMS frontline;
private final ContactDao contactDao;
private final KeywordDao keywordDao;
private final KeywordActionDao keywordActionDao;
private final GroupDao groupDao;
private final GroupMembershipDao groupMembershipDao;
private final MessageDao messageDao;
private EmailDao emailDao;
private UIListener uiListener;
/** Set of listeners for incoming message events. */
private Set<IncomingMessageListener> incomingMessageListeners = new HashSet<IncomingMessageListener>();
private final EmailServerHandler emailServerHandler;
/** Create a new {@link IncomingMessageProcessor}, and initialise properties. */
public IncomingMessageProcessor(FrontlineSMS frontline) {
super("Incoming message processor");
this.frontline = frontline;
this.contactDao = frontline.getContactDao();
this.keywordDao = frontline.getKeywordDao();
this.keywordActionDao = frontline.getKeywordActionDao();
this.groupDao = frontline.getGroupDao();
this.groupMembershipDao = frontline.getGroupMembershipDao();
this.messageDao = frontline.getMessageDao();
this.emailDao = frontline.getEmailDao();
this.emailServerHandler = frontline.getEmailServerHandler();
}
public void setUiListener(UIListener uiListener) {
this.uiListener = uiListener;
}
public void queue(SmsService receiver, CIncomingMessage incomingMessage) {
LOG.trace("Adding message to queue: " + receiver.hashCode() + ":" + incomingMessage.hashCode());
incomingMessageQueue.add(new IncomingMessageDetails(receiver, incomingMessage));
}
public void queue(MmsMessage mms) {
LOG.trace("Adding MMS to queue:" + mms.hashCode());
incomingMessageQueue.add(new IncomingMms(mms));
}
public void die() {
keepAlive = false;
incomingMessageQueue.add(new IncomingMessageProcessorQueueKiller());
}
public void run() {
this.keepAlive = true;
while(keepAlive) {
IncomingMessageProcessorQueueItem queueItem = null;
LOG.trace("Getting incoming message from queue.");
try {
queueItem = incomingMessageQueue.take();
} catch(InterruptedException ex) {
LOG.warn("Thread interrupted.", ex);
}
if (queueItem == null) {
// we may have popped out when queue was notified, which means job may be null
LOG.trace("There were no messages in the queue.");
continue;
} else {
if(queueItem instanceof IncomingMessageProcessorQueueKiller) {
// We have been given a "poisoned" item so must terminate this thread
keepAlive = false;
} else {
try {
// We've got a new message, so process it.
processIncomingMessageDetails(queueItem);
} catch(Throwable t) {
// There was a problem processing the message. At this stage, any issue should be a database
// connectivity issue. Stop processing messages for a while, and re-queue this one.
LOG.warn("Error processing message. It will be queued for re-processing.", t);
incomingMessageQueue.add(queueItem);
FrontlineUtils.sleep_ignoreInterrupts(THREAD_SLEEP_AFTER_PROCESSING_FAILED);
}
}
}
}
LOG.trace("EXIT");
}
private void processIncomingMessageDetails(IncomingMessageProcessorQueueItem queueItem) {
if (queueItem instanceof IncomingMms) {
// Creates the FrontlineMultimediaMessage
FrontlineMultimediaMessage mms = MmsUtils.create(((IncomingMms) queueItem).getMessage());
this.messageDao.saveMessage(mms);
handleMessage(mms);
} else if (queueItem instanceof IncomingMessageDetails) {
IncomingMessageDetails incomingMessageDetails = (IncomingMessageDetails) queueItem;
CIncomingMessage incomingMessage = incomingMessageDetails.getMessage();
SmsService receiver = incomingMessageDetails.getReceiver();
LOG.trace("Got message from queue: " + receiver.hashCode() + ":" + incomingMessage.hashCode());
// Check the incoming message details with the KeywordFactory to make sure there are no details
// that should be hidden before creating the message object...
String incomingSenderMsisdn = incomingMessage.getOriginator();
LOG.debug("Sender [" + incomingSenderMsisdn + "]");
if (incomingMessage.getType() == CIncomingMessage.MessageType.StatusReport) {
handleStatusReport(incomingMessage);
} else {
// This is an incoming message, so process accordingly
FrontlineMessage incoming;
if (incomingMessage.getMessageEncoding() == SmsMessageEncoding.GSM_7BIT || incomingMessage.getMessageEncoding() == SmsMessageEncoding.UCS2) {
if(LOG.isDebugEnabled()) LOG.debug("Incoming text message [" + incomingMessage.getText() + "]");
incoming = FrontlineMessage.createIncomingMessage(incomingMessage.getDate(), incomingSenderMsisdn, receiver.getMsisdn(), incomingMessage.getText());
messageDao.saveMessage(incoming);
handleMessage(incoming);
} else {
if(LOG.isDebugEnabled()) LOG.debug("Incoming binary message: " + incomingMessage.getBinary().length + "b");
// Save the binary message
incoming = FrontlineMessage.createBinaryIncomingMessage(incomingMessage.getDate(), incomingSenderMsisdn, receiver.getMsisdn(), -1, incomingMessage.getBinary());
messageDao.saveMessage(incoming);
}
for(IncomingMessageListener listener : this.incomingMessageListeners) {
listener.incomingMessageEvent(incoming);
}
if (uiListener != null) {
uiListener.incomingMessageEvent(incoming);
}
}
} else {
LOG.error("Unknown queue item type: " + queueItem.getClass());
}
}
/**
* Process an incoming status report. The status should be set to
* @param incomingMessage The incoming status report.
*/
private void handleStatusReport(CIncomingMessage incomingMessage) {
assert(incomingMessage.getType() == MessageType.StatusReport) : "This method can ONLY be called on an incoming status report.";
// Match the status report with a previously sent message, and update that message's
// status. If no message is found to match this to, just ditch the status report. This
// means that shredding is of no concern here.
CStatusReportMessage statusReport = (CStatusReportMessage) incomingMessage;
// Here, we strip the first four characters off the originator's number. This is because we
// cannot be sure if the numbers supplied by the PhoneHandler are localised, or international
// with or without leading +.
FrontlineMessage message = messageDao.getMessageForStatusUpdate(statusReport.getOriginator(), incomingMessage.getRefNo());
if (message != null) {
LOG.debug("It's a delivery report for message [" + message + "]");
switch(statusReport.getDeliveryStatus()) {
case CStatusReportMessage.DeliveryStatus.Delivered:
message.setStatus(Status.DELIVERED);
break;
case CStatusReportMessage.DeliveryStatus.Aborted:
message.setStatus(Status.FAILED);
break;
}
if (uiListener != null) {
uiListener.outgoingMessageEvent(message);
}
}
}
/**
* Processes keyword actions for a text message.
* @param message
*/
/* not private to allow unit testing */
void handleMessage(final FrontlineMessage message) {
Keyword keyword;
if (message instanceof FrontlineMultimediaMessage) {
keyword = keywordDao.getKeyword(FrontlineSMSConstants.MMS_KEYWORD);
} else {
keyword = keywordDao.getFromMessageText(message.getTextContent());
}
if (keyword != null) {
LOG.debug("The message contains keyword [" + keyword.getKeyword() + "]");
final Collection<KeywordAction> actions = this.keywordActionDao.getActions(keyword);
// TODO process pre-message actions (e.g. "shred") TODO this should actually be done BEFORE the message object is persisted
if(actions.size() > 0) {
LOG.debug("Executing actions for keyword, if the contact is allowed!");
Contact contact = contactDao.getFromMsisdn(message.getSenderMsisdn());
//If we could not find this contact, we execute the action.
//If we found a contact, he/she needs to be allowed to execute the action.
if (contact == null || contact.isActive()) {
final long triggerTime = message.getDate();
for (KeywordAction action : actions) {
if (action.isAlive(triggerTime)) {
try {
handleIncomingMessageAction_post(action, message);
} catch(Exception ex) {
LOG.warn("Exception thrown while executing action.", ex);
}
}
}
}
}
}
}
/**
* Handle relevant incoming message actions AFTER the message has been created with the messageFactory.
* @param action The action to executed.
* @param incoming The incoming message that triggered this action.
*/
private void handleIncomingMessageAction_post(KeywordAction action, FrontlineMessage incoming) {
LOG.trace("ENTER");
String incomingSenderMsisdn = incoming.getSenderMsisdn();
String incomingMessageText = incoming.getTextContent();
switch (action.getType()) {
case NO_ACTION:
// This action is used for testing, and causes nothing to happen
break;
case FORWARD:
// Generate a message, and then forward it to the group attached to this action.
LOG.debug("It is a forward action!");
String forwardedMessageText = KeywordAction.KeywordUtils.getForwardText(action, contactDao.getFromMsisdn(incomingSenderMsisdn), incomingSenderMsisdn, incomingMessageText);
LOG.debug("Message to forward [" + forwardedMessageText + "]");
for (Contact contact : this.groupMembershipDao.getActiveMembers(action.getGroup())) {
LOG.debug("Sending to [" + contact.getName() + "]");
frontline.sendTextMessage(contact.getPhoneNumber(), KeywordAction.KeywordUtils.personaliseMessage(contact, forwardedMessageText));
}
break;
case JOIN: {
LOG.debug("It is a group join action!");
// If the contact does not exist, we need to persist him so that we can add him to a group.
// Otherwise, get the contact from the database.
Contact contact = contactDao.getFromMsisdn(incomingSenderMsisdn);
try {
if (contact == null) {
contact = new Contact("", incomingSenderMsisdn, null, null, null, true);
contactDao.saveContact(contact);
}
Group group = action.getGroup();
LOG.debug("Adding contact [" + contact.getName() + "], Number [" + contact.getPhoneNumber() + "] to Group [" + group.getName() + "]");
boolean contactAdded = this.groupMembershipDao.addMember(group, contact);
if(contactAdded) {
groupDao.updateGroup(group);
if(uiListener != null) {
uiListener.contactAddedToGroup(contact, group);
}
}
} catch(DuplicateKeyException ex) {
// Due to previous check, this should never be thrown...
// Not much we can do if it is!
// FIXME throwing this exception could spit out otherwise-shredded data
// into the logs!
throw new RuntimeException(ex);
}
} break;
case LEAVE: {
LOG.debug("It is a group leave action!");
Contact contact = contactDao.getFromMsisdn(incomingSenderMsisdn);
if (contact != null) {
Group group = action.getGroup();
LOG.debug("Removing contact [" + contact.getName() + "] from Group [" + group.getName() + "]");
if(this.groupMembershipDao.removeMember(group, contact)) {
this.groupDao.updateGroup(group);
}
if (uiListener != null) {
uiListener.contactRemovedFromGroup(contact, group);
}
}
} break;
case REPLY:
// Generate a message, and then send it back to the sender of the received message.
LOG.debug("It is an auto-reply action!");
String reply = KeywordAction.KeywordUtils.getReplyText(action, contactDao.getFromMsisdn(incomingSenderMsisdn), incomingSenderMsisdn, incomingMessageText, null);
LOG.debug("Sending [" + reply + "] to [" + incomingSenderMsisdn + "]");
frontline.sendTextMessage(incomingSenderMsisdn, reply);
// TODO should the message be tied to the action somehow?
break;
case EXTERNAL_CMD:
// Executes a external command
LOG.debug("It is an external command action!");
try {
executeExternalCommand(action, incomingSenderMsisdn, incomingMessageText);
} catch (IOException e) {
LOG.debug("Problem executing external command.", e);
} catch (InterruptedException e) {
LOG.debug("Problem executing external command.", e);
} catch (JDOMException e) {
LOG.debug("Problem executing external command.", e);
}
break;
case EMAIL:
LOG.debug("It is an e-mail action!");
Email email = new Email(
action.getEmailAccount(),
action.getEmailRecipients(),
KeywordAction.KeywordUtils.getEmailSubject(action, contactDao.getFromMsisdn(incomingSenderMsisdn), incomingSenderMsisdn, incomingMessageText, null),
KeywordAction.KeywordUtils.getReplyText(action, contactDao.getFromMsisdn(incomingSenderMsisdn), incomingSenderMsisdn, incomingMessageText, null)
);
emailDao.saveEmail(email);
LOG.debug("Sending [" + email.getEmailContent() + "] from [" + email.getEmailFrom().getAccountName() + "] to [" + email.getEmailRecipients() + "]");
emailServerHandler.sendEmail(email);
break;
}
this.keywordActionDao.incrementCounter(action);
if (uiListener != null) {
uiListener.keywordActionExecuted(action);
}
LOG.debug("Number of hits for this action [" + action + "] is [" + action.getCounter() + "]");
LOG.trace("EXIT");
}
/**
* Executes a external command (HTTP or Command Line) and treats its response according to what is defined in the action.
* @param action
* @param incomingSenderMsisdn
* @param incomingMessageText
* @throws IOException
* @throws InterruptedException
* @throws JDOMException
*/
/* not private to allow unit testing */
void executeExternalCommand(KeywordAction action, String incomingSenderMsisdn, String incomingMessageText) throws IOException, InterruptedException, JDOMException {
LOG.trace("ENTER");
String cmd = KeywordAction.KeywordUtils.getExternalCommand(
action,
contactDao.getFromMsisdn(incomingSenderMsisdn),
incomingSenderMsisdn,
incomingMessageText
);
LOG.debug("Command to be executed [" + cmd + "]");
if (action.getExternalCommandResponseType() != ExternalCommandResponseType.LIST_COMMANDS) {
//Executes the command and handle the response as plain text, or no response at all.
String response;
LOG.debug("Response will be plain text or nothing at all.");
boolean waitForResponse = action.getExternalCommandResponseType() == ExternalCommandResponseType.PLAIN_TEXT;
if (action.getExternalCommandType() == KeywordAction.ExternalCommandType.HTTP_REQUEST) {
LOG.debug("Executing HTTP request...");
response = FrontlineUtils.makeHttpRequest(cmd, waitForResponse);
} else {
LOG.debug("Executing external program...");
response = FrontlineUtils.executeExternalProgram(cmd, waitForResponse);
}
if (waitForResponse) {
LOG.debug("Response [" + response + "]");
handleExternalCommandResponse(action, incomingSenderMsisdn, response);
}
} else {
//LIST OF COMMANDS TO EXECUTE
LOG.debug("Response will be an XML with Frontline Commands.");
InputStream toRead = null;
if (action.getExternalCommandType() == KeywordAction.ExternalCommandType.HTTP_REQUEST) {
LOG.debug("Executing HTTP request...");
toRead = FrontlineUtils.makeHttpRequest(cmd);
} else {
LOG.debug("Executing external program...");
toRead = FrontlineUtils.executeExternalProgram(cmd);
}
LOG.debug("Reading XML from response...");
XMLReader reader = new XMLReader(toRead);
for (XMLMessage msg : reader.readMessages()) {
LOG.debug("Message found!");
LOG.debug("Data [" + msg.getData() + "]");
if (msg.getType() == XMLMessage.TYPE_TEXT) {
//We add everything to the numbers list, to send in the end.
//Contacts
for (String contact : msg.getToContacts()) {
Contact c = contactDao.getContactByName(contact);
if (c!= null && c.isActive()) {
msg.addNumber(c.getPhoneNumber());
}
}
//Groups
for (String group : msg.getToGroups()) {
Group g = groupDao.getGroupByPath(group);
if (g != null) {
for(Contact c : this.groupMembershipDao.getActiveMembers(g)) {
if (c.isActive()) {
msg.addNumber(c.getPhoneNumber());
}
}
}
}
//All recipients are in the numbers list now.
for (String number : msg.getToNumbers()) {
LOG.debug("Sending to [" + number + "]");
frontline.sendTextMessage(number, msg.getData());
}
} else {
//TODO BINARY MESSAGE
}
}
}
LOG.trace("EXIT");
}
/**
* Handles the command response for this action.
* @param action
* @param incomingSenderMsisdn
* @param response
*/
private void handleExternalCommandResponse(KeywordAction action, String incomingSenderMsisdn,
String response) {
assert(action.getType() == KeywordAction.Type.EXTERNAL_CMD) : "This method should only be called on external command actions.";
// PLAIN TEXT RESPONSE so we need to verify if the user wants
// to auto reply forward the response
LOG.trace("ENTER");
ExternalCommandResponseActionType responseActionType = action.getCommandResponseActionType();
if (responseActionType == KeywordAction.ExternalCommandResponseActionType.DO_NOTHING) {
LOG.debug("Nothing to do with the response!");
LOG.trace("EXIT");
return;
}
String message = KeywordAction.KeywordUtils.getExternalCommandReplyMessage(action, response);
LOG.debug("Message to forward [" + message + "]");
if (responseActionType == KeywordAction.ExternalCommandResponseActionType.REPLY
|| responseActionType == KeywordAction.ExternalCommandResponseActionType.REPLY_AND_FORWARD) {
//Auto reply
LOG.debug("Sending to [" + incomingSenderMsisdn + "] as an auto-reply.");
frontline.sendTextMessage(incomingSenderMsisdn, message);
}
if (responseActionType == KeywordAction.ExternalCommandResponseActionType.FORWARD
|| responseActionType == KeywordAction.ExternalCommandResponseActionType.REPLY_AND_FORWARD) {
//Forwarding to a group
Group fwd = action.getGroup();
LOG.debug("Forwarding to group [" + fwd.getName() + "]");
for(Contact contact : this.groupMembershipDao.getActiveMembers(fwd)) {
if (contact.isActive()) {
if (responseActionType != KeywordAction.ExternalCommandResponseActionType.REPLY_AND_FORWARD
|| !contact.getPhoneNumber().equalsIgnoreCase(incomingSenderMsisdn)) {
//If we have already replied to the sender and he/she is on the group to forward
//so we don't send the message again.
LOG.debug("Sending to contact [" + contact.getName() + "]");
}
frontline.sendTextMessage(contact.getPhoneNumber(), message);
}
}
}
LOG.trace("EXIT");
}
/**
* Adds another {@link IncomingMessageListener} to {@link #incomingMessageListeners}.
* @param incomingMessageListener new {@link IncomingMessageListener}
*/
public void addIncomingMessageListener(IncomingMessageListener incomingMessageListener) {
this.incomingMessageListeners.add(incomingMessageListener);
}
/**
* Removes a {@link IncomingMessageListener} from {@link #incomingMessageListeners}.
* @param incomingMessageListener {@link IncomingMessageListener} to be removed
*/
public void removeIncomingMessageListener(IncomingMessageListener incomingMessageListener) {
this.incomingMessageListeners.remove(incomingMessageListener);
}
}
/** Empty interface implemented by items which are put in the {@link IncomingMessageProcessor}'s queue. */
interface IncomingMessageProcessorQueueItem {}
/**
* Queue item which contains details of an incoming message.
* @author Alex
*/
class IncomingMessageDetails implements IncomingMessageProcessorQueueItem {
/** the message received */
private final CIncomingMessage message;
/** the device the message was received on */
private final SmsService receiver;
//> CONSTRUCTOR
/**
* @param receiver The device which this message was received on.
* @param message The message
*/
public IncomingMessageDetails(SmsService receiver, CIncomingMessage message) {
this.receiver = receiver;
this.message = message;
}
//> ACCESSORS
/** @return the message received */
public CIncomingMessage getMessage() {
return message;
}
/** @return the device the message was received on */
public SmsService getReceiver() {
return receiver;
}
}
/**
* Queue item which contains an MMS
* @author Morgan Belkadi <morgan@frontlinesms.com>
*/
class IncomingMms implements IncomingMessageProcessorQueueItem {
/** the message received */
private final MmsMessage message;
//> CONSTRUCTOR
/**
* @param receiver The device which this message was received on.
* @param message The message
*/
public IncomingMms(MmsMessage message) {
this.message = message;
}
//> ACCESSORS
/** @return the message received */
public MmsMessage getMessage() {
return message;
}
}
/**
* Queuing an instance of this class will kill the {@link IncomingMessageProcessor}.
* @author Alex
*/
class IncomingMessageProcessorQueueKiller implements IncomingMessageProcessorQueueItem {}