com.ninetyslide.libs.botforge.FbBot.java Source code

Java tutorial

Introduction

Here is the source code for com.ninetyslide.libs.botforge.FbBot.java

Source

/*
 * Copyright 2016 NinetySlide
 *
 * 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 com.ninetyslide.libs.botforge;

import com.google.gson.*;
import com.ninetyslide.libs.botforge.core.BotContext;
import com.ninetyslide.libs.botforge.core.message.incoming.*;
import com.ninetyslide.libs.botforge.util.BotContextManager;
import com.ninetyslide.libs.botforge.util.GsonManager;
import com.ninetyslide.libs.botforge.util.SignatureVerifier;

import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.logging.Logger;

import static com.ninetyslide.libs.botforge.common.Constants.*;
// TODO: Setup unit tests

/**
 * Main class that handles the Bot. This class is an HttpServlet that receives GET and POST HTTP calls, extract the
 * messages and delivers them to a series of callbacks, each one of them for a specific event.
 *
 * A Bot should override one or more of these callbacks to handle the events and the received messages, otherwise they
 * will just be ignored (default behaviour).
 *
 * Please note that a single POST HTTP call can carry a batch of messages. When this happens, the messages will be
 * parsed sequentially, triggering a callback invocation for each message. So, you are advised to not perform heavy
 * load work inside the callbacks to avoid slowing down message processing. If you need to perform heavy computation
 * for every message received, it is recommended to spawn a different thread (if your environment allows you to do so)
 * or to use some sort of task queue.
 */
public abstract class FbBot extends HttpServlet {

    private static final Logger log = Logger.getLogger(FbBot.class.getName());

    private Gson gson = null;
    private JsonParser parser = null;

    protected BotContextManager contextManager;

    /**
     * Method that creates the BotContext object starting from parameters set in the deployment descriptor and then
     * invoke the botInit() method to let the Bot perform its specific initialization.
     *
     * @param config The config object used to retrieve the parameters.
     * @throws ServletException When there is a Servlet related error.
     */
    @Override
    public final void init(ServletConfig config) throws ServletException {
        super.init(config);

        // Initialize all the fields
        gson = GsonManager.getGsonInstance();
        parser = GsonManager.getJsonParserInstance();
        contextManager = BotContextManager.getInstance();

        // Call the method for Bot-specific initialization
        List<BotContext> contexts = botInit();

        // Add all returned contexts to the context manager, if any
        if (contexts != null && !contexts.isEmpty()) {
            for (BotContext context : contexts) {
                contextManager.addContext(context);
            }
        }
    }

    /**
     * This method is only used to receive Webhook Validations. It retrieves the BotContext using the request URL and
     * uses the Verify Token associated with the context to match the one provided in the request. In case of matching,
     * it sends back the value of the "challenge" parameter.
     *
     * @param req The request object.
     * @param resp The response object.
     * @throws ServletException When there is a Servlet related error.
     * @throws IOException When there is an I/O error.
     */
    @Override
    protected final void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {

        // Retrieve the values from the request
        String webhookUrl = req.getRequestURL().toString();
        String mode = req.getParameter(WEBHOOK_VALIDATION_PARAM_NAME_MODE);
        String verifyToken = req.getParameter(WEBHOOK_VALIDATION_PARAM_NAME_VERIFY_TOKEN);
        String challenge = req.getParameter(WEBHOOK_VALIDATION_PARAM_NAME_CHALLENGE);

        // Retrieve the context or fail if the context is not found
        BotContext context = retrieveContext(null, webhookUrl);
        if (context == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        // Log the request data if debug is enabled
        if (context.isDebugEnabled()) {
            log.info("URL: " + webhookUrl + "\n" + "Mode: " + mode + "\n" + "Verify Token: " + verifyToken + "\n"
                    + "Challenge: " + challenge + "\n");
        }

        // Check whether the mode is right and the token match
        if (WEBHOOK_VALIDATION_MODE_SUBSCRIBE.equals(mode) && context.getVerifyToken().equals(verifyToken)
                && challenge != null) {

            // Set the HTTP Headers
            resp.setStatus(HttpServletResponse.SC_OK);
            resp.setContentType(HTTP_CONTENT_TYPE_TEXT);
            resp.setCharacterEncoding(HTTP_CHAR_ENCODING);

            // Write the challenge back to Facebook
            PrintWriter respWriter = resp.getWriter();
            respWriter.write(challenge);
            respWriter.flush();
            respWriter.close();

        } else {
            // Send back an error in case something went wrong
            resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
        }
    }

    /**
     * This method handles all the callbacks headed to the Webhook other than the Webhook Validation. It retrieves the
     * BotContext using the request URL, verifies the signature of the request (if enabled), parses the message received
     * via the Webhook and then delivers the parsed message to the right callback, depending on the message type.
     *
     * @param req The request object.
     * @param resp The response object.
     * @throws ServletException When there is a Servlet related error.
     */
    @Override
    protected final void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException {

        // TODO: Add support for uploaded file in incoming and outgoing Attachment Messages
        // Get the URL of the request
        String webhookUrl = req.getRequestURL().toString();

        // Retrieve the context or fail if the context is not found
        BotContext context = retrieveContext(null, webhookUrl);
        if (context == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        // Get the signature header
        String signatureHeader = req.getHeader(HTTP_HEADER_SIGNATURE);

        // Get the JSON String
        String jsonStr;
        try {
            jsonStr = extractJsonString(req.getReader());
        } catch (IOException e) {
            resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            return;
        }

        // If debug is enabled, print the JSON and the signature header
        if (context.isDebugEnabled()) {
            log.info("Signature Header: " + signatureHeader + "\n" + "Raw JSON: " + jsonStr + "\n");
        }

        // Verify the signature using HMAC-SHA1 and send back an error if verification fails
        if (context.isCallbacksValidationActive()
                && !SignatureVerifier.verifySignature(jsonStr, signatureHeader, context.getAppSecretKey())) {
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        // Parse the JSON String
        JsonObject rawMessage = parser.parse(jsonStr).getAsJsonObject();

        // Process every message of the batch
        JsonArray entries = rawMessage.getAsJsonArray(JSON_CALLBACK_FIELD_NAME_ENTRY);

        // If there are no entries, send back an error and just return
        if (entries == null) {
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        // If there were no errors, answer with HTTP Code 200 to Facebook server as soon as possible
        try {
            resp.setStatus(HttpServletResponse.SC_OK);
            resp.flushBuffer();
        } catch (IOException e) {
            return;
        }

        for (JsonElement rawEntry : entries) {

            JsonObject entry = rawEntry.getAsJsonObject();
            JsonArray messages = entry.getAsJsonArray(JSON_CALLBACK_FIELD_NAME_MESSAGING);

            // If there are no messages, go on with the next entry
            if (messages == null) {
                continue;
            }

            for (JsonElement messageRaw : messages) {
                JsonObject message = messageRaw.getAsJsonObject();
                JsonObject content;
                IncomingMessage incomingMessage;

                if ((content = message.getAsJsonObject(JSON_CALLBACK_TYPE_NAME_MESSAGE)) != null) {

                    // It's a message received, parse it correctly based on the sub type
                    if (content.get(JSON_CALLBACK_SUB_TYPE_NAME_TEXT) != null) {
                        incomingMessage = gson.fromJson(content, IncomingTextMessage.class);
                    } else if (content.getAsJsonArray(JSON_CALLBACK_SUB_TYPE_NAME_ATTACHMENTS) != null) {
                        incomingMessage = gson.fromJson(content, IncomingAttachmentMessage.class);
                    } else {
                        // Can't send an error to the server anymore, try to process as much as you can of the message
                        continue;
                    }

                    // Set Sender ID, Recipient ID and Timestamp
                    setMessageHeaders(message, incomingMessage);

                    // Deliver the message to the right callback based on the type
                    ReceivedMessage receivedMessage = (ReceivedMessage) incomingMessage;
                    if (receivedMessage.isEcho()) {
                        onMessageEchoReceived(context, receivedMessage);
                    } else {
                        onMessageReceived(context, receivedMessage);
                    }

                } else if ((content = message.getAsJsonObject(JSON_CALLBACK_TYPE_NAME_POSTBACK)) != null) {

                    // Parse the message as a postback message
                    incomingMessage = gson.fromJson(content, Postback.class);

                    // Set Sender ID, Recipient ID and Timestamp
                    setMessageHeaders(message, incomingMessage);

                    // Deliver the message to the postback callback
                    onPostbackReceived(context, (Postback) incomingMessage);

                } else if ((content = message.getAsJsonObject(JSON_CALLBACK_TYPE_NAME_OPTIN)) != null) {

                    // Parse the message as an authentication callback
                    incomingMessage = gson.fromJson(content, Optin.class);

                    // Set Sender ID, Recipient ID and Timestamp
                    setMessageHeaders(message, incomingMessage);

                    // Deliver the message to the authentication callback
                    onAuthenticationReceived(context, (Optin) incomingMessage);

                } else if ((content = message.getAsJsonObject(JSON_CALLBACK_TYPE_NAME_ACCOUNT_LINKING)) != null) {

                    // Parse the message as an account linking callback
                    incomingMessage = gson.fromJson(content, AccountLinking.class);

                    // Set Sender ID, Recipient ID and Timestamp
                    setMessageHeaders(message, incomingMessage);

                    // Deliver the message to the account linking callback
                    onAccountLinkingReceived(context, (AccountLinking) incomingMessage);

                } else if ((content = message.getAsJsonObject(JSON_CALLBACK_TYPE_NAME_DELIVERY)) != null) {

                    // Parse the message as a delivery receipt
                    incomingMessage = gson.fromJson(content, DeliveryReceipt.class);

                    // Set Sender ID, Recipient ID and Timestamp
                    setMessageHeaders(message, incomingMessage);

                    // Deliver the message to the message delivery callback
                    onMessageDelivered(context, (DeliveryReceipt) incomingMessage);

                } else if ((content = message.getAsJsonObject(JSON_CALLBACK_TYPE_NAME_READ)) != null) {

                    // Parse the message as a read receipt
                    incomingMessage = gson.fromJson(content, ReadReceipt.class);

                    // Set Sender ID, Recipient ID and Timestamp
                    setMessageHeaders(message, incomingMessage);

                    // Deliver the message to the message read callback
                    onMessageRead(context, (ReadReceipt) incomingMessage);

                }
            }
        }

    }

    /**
     * Retrieve the context using one of either pageId or webhook, and falling back to invocation of onContextLoad if
     * the context is not present in the context manager.
     *
     * @param pageId The Page ID associated with the context.
     * @param webhookUrl The Webhook URL associated with the context.
     * @return The context retrieved or null if the context was not found.
     */
    private BotContext retrieveContext(String pageId, String webhookUrl) {
        String contextKey;

        // Decide which variable to use as the context key
        if (pageId != null) {
            contextKey = pageId;
        } else if (webhookUrl != null) {
            contextKey = webhookUrl;
        } else {
            return null;
        }

        // Retrieve the context from the context manager or invoke onContextLoad
        if (contextManager.containsContext(contextKey)) {
            return contextManager.getContext(contextKey);
        } else {
            return onContextLoad(pageId, webhookUrl);
        }
    }

    /**
     * Read the JSON String from the request body.
     *
     * @param jsonReader The BufferedReader from the request.
     * @return The String representing the JSON.
     * @throws IOException When there is an I/O error.
     */
    private String extractJsonString(BufferedReader jsonReader) throws IOException {
        String jsonPartial;
        StringBuilder jsonRaw = new StringBuilder();

        while ((jsonPartial = jsonReader.readLine()) != null) {
            jsonRaw.append(jsonPartial);
        }

        return jsonRaw.toString();
    }

    /**
     * Set the Sender ID, Recipient ID and Timestamp in the incoming message.
     *
     * @param rawMessage The raw JSON Object received via the callback.
     * @param message The extracted IncomingMessage.
     * @return The IncomingMessage passed as input, with the values set.
     */
    private IncomingMessage setMessageHeaders(JsonObject rawMessage, IncomingMessage message) {

        message.setSenderId(rawMessage.getAsJsonObject(JSON_CALLBACK_FIELD_NAME_SENDER)
                .get(JSON_CALLBACK_FIELD_NAME_ID).getAsString());

        message.setRecipientId(rawMessage.getAsJsonObject(JSON_CALLBACK_FIELD_NAME_RECIPIENT)
                .get(JSON_CALLBACK_FIELD_NAME_ID).getAsString());

        message.setTimestamp(rawMessage.get(JSON_CALLBACK_FIELD_NAME_TIMESTAMP).getAsLong());

        return message;

    }

    /**
     * Method invoked only once, when the Bot is first initialized, to perform some custom Bot-specific initializations
     * and to bulk load BotContext object inside the BotContextManager. The default implementation just returns null.
     * The override of this method is optional.
     *
     * Initializations aside, this is the perfect place to bulk load BotContext objects without relying on the lazy
     * loading performed when a context is first needed. This method is called only once when the Bot is first
     * initialized.
     *
     * @return A list of BotContext objects to add to the BotContextManager.
     */
    protected List<BotContext> botInit() {
        return null;
    }

    /**
     * Callback invoked when a Text or Attachment message is received. To access all the information, the message type
     * shall be inspected and the message passed as an argument shall be cast appropriately. The parameters contain
     * everything is needed to perform actions in response to the event. The default implementation does just nothing.
     * The overriding of this method is optional.
     *
     * @param context The context of the Bot associated with this request.
     * @param message The message received via the Webhook.
     */
    protected void onMessageReceived(BotContext context, ReceivedMessage message) {
    }

    /**
     * Callback invoked when a Postback message is received. The parameters contain everything is needed to perform
     * actions in response to the event. The default implementation does just nothing. The overriding of this method
     * is optional.
     *
     * @param context The context of the Bot associated with this request.
     * @param message The message received via the Webhook.
     */
    protected void onPostbackReceived(BotContext context, Postback message) {
    }

    /**
     * Callback invoked when an Authentication (Optin) message is received. The parameters contain everything is
     * needed to perform actions in response to the event. The default implementation does just nothing. The overriding
     * of this method is optional.
     *
     * @param context The context of the Bot associated with this request.
     * @param message The message received via the Webhook.
     */
    protected void onAuthenticationReceived(BotContext context, Optin message) {
    }

    /**
     * Callback invoked when a Delivery Confirmation message is received. The parameters contain everything is needed
     * to perform actions in response to the event. The default implementation does just nothing. The overriding of
     * this method is optional.
     *
     * @param context The context of the Bot associated with this request.
     * @param message The message received via the Webhook.
     */
    protected void onMessageDelivered(BotContext context, DeliveryReceipt message) {
    }

    /**
     * Callback invoked when a Read Confirmation message is received. The parameters contain everything is needed to
     * perform actions in response to the event. The default implementation does just nothing. The overriding of this
     * method is optional.
     *
     * @param context The context of the Bot associated with this request.
     * @param message The message received via the Webhook.
     */
    protected void onMessageRead(BotContext context, ReadReceipt message) {
    }

    /**
     * Callback invoked when a Message Echo is received. The parameters contain everything is needed to perform
     * actions in response to the event. The default implementation does just nothing. The overriding of this method
     * is optional.
     *
     * @param context The context of the Bot associated with this request.
     * @param message The message received via the Webhook.
     */
    protected void onMessageEchoReceived(BotContext context, ReceivedMessage message) {
    }

    /**
     * Callback invoked when an Account Linking message is received. The parameters contain everything is needed to
     * perform actions in response to the event. The default implementation does just nothing. The overriding of this
     * method is optional.
     *
     * @param context The context of the Bot associated with this request.
     * @param message The message received via the Webhook.
     */
    protected void onAccountLinkingReceived(BotContext context, AccountLinking message) {
    }

    /**
     * Callback invoked when the context is not found inside the BotContextManager. This gives the chance to lazy load
     * the contexts. Please note that only one of the two parameters will be set at invocation time. They will never be
     * both set at the same time. The implementation must be prepared to work with either one of the parameter is set
     * at invocation time. The implementation of this method is mandatory.
     *
     * Please note that a BotContext can be loaded, removed and modified anytime inside the BotContextManager just by
     * using the BotContextManager instance provided as a field of this class.
     *
     * @param pageId The Page ID associated with the context.
     * @param webhookUrl The webhookUrl associated with the context.
     * @return The context associated with the identifiers passed as arguments.
     */
    protected abstract BotContext onContextLoad(String pageId, String webhookUrl);

}