diff --git a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/MessengerPlatform.java b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/MessengerPlatform.java index 5eb9128..c1eb612 100644 --- a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/MessengerPlatform.java +++ b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/MessengerPlatform.java @@ -1,22 +1,30 @@ package com.xatkit.plugins.messenger.platform; +import com.google.gson.JsonElement; import com.xatkit.core.XatkitBot; import com.xatkit.core.platform.RuntimePlatform; import com.xatkit.core.server.*; import com.xatkit.execution.StateContext; import com.xatkit.plugins.messenger.platform.action.*; -import com.xatkit.plugins.messenger.platform.entity.Message; -import com.xatkit.plugins.messenger.platform.entity.Messaging; -import com.xatkit.plugins.messenger.platform.entity.Recipient; -import com.xatkit.plugins.messenger.platform.entity.SenderAction; +import com.xatkit.plugins.messenger.platform.entity.*; +import com.xatkit.plugins.messenger.platform.entity.payloads.AttachmentIdPayload; +import com.xatkit.plugins.messenger.platform.entity.response.ErrorResponse; +import com.xatkit.plugins.messenger.platform.entity.response.Response; +import com.xatkit.plugins.messenger.platform.entity.response.SendResponse; import com.xatkit.plugins.rest.platform.RestPlatform; +import com.xatkit.plugins.rest.platform.action.JsonRestRequest; import com.xatkit.plugins.rest.platform.utils.ApiResponse; import lombok.NonNull; import fr.inria.atlanmod.commons.log.Log; import lombok.val; +import lombok.var; import org.apache.commons.configuration2.Configuration; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpStatus; import org.apache.http.entity.StringEntity; +import java.nio.charset.StandardCharsets; + import static java.util.Objects.requireNonNull; // TODO: Add javadocs @@ -28,69 +36,110 @@ public class MessengerPlatform extends RestPlatform { private String verifyToken; private String accessToken; private String appSecret; - private int messagesSent; - private int messagesSentSuccessfully; @Override public void start(@NonNull XatkitBot xatkitBot, @NonNull Configuration configuration) { verifyToken = requireNonNull(configuration.getString(MessengerUtils.VERIFY_TOKEN_KEY)); accessToken = requireNonNull(configuration.getString(MessengerUtils.ACCESS_TOKEN_KEY)); appSecret = requireNonNull(configuration.getString(MessengerUtils.APP_SECRET_KEY)); - messagesSent = 0; - messagesSentSuccessfully = 0; super.start(xatkitBot, configuration); - xatkitBot.getXatkitServer().registerRestEndpoint(HttpMethod.GET, "/messenger/webhook", + xatkitBot.getXatkitServer().registerRestEndpoint(HttpMethod.GET, MessengerUtils.WEBHOOK_URI, RestHandlerFactory.createEmptyContentRestHandler((headers, params, content) -> { val mode = requireNonNull(HttpUtils.getParameterValue("hub.mode", params), "Missing mode"); val token = requireNonNull(HttpUtils.getParameterValue("hub.verify_token", params), "Missing token"); val challenge = requireNonNull(HttpUtils.getParameterValue("hub.challenge", params), "Missing challenge"); if (!mode.equals("subscribe")) { - throw new RestHandlerException(403, "Mode is not 'subscribe'"); + throw new RestHandlerException(HttpStatus.SC_FORBIDDEN, "Mode is not 'subscribe'"); } if (!token.equals(verifyToken)) { - throw new RestHandlerException(403, "Token does not match verify token."); + throw new RestHandlerException(HttpStatus.SC_FORBIDDEN, "Token does not match verify token."); } - return new StringEntity(challenge, "UTF-8"); + return new StringEntity(challenge, StandardCharsets.UTF_8); })); } - public void markSeen(@NonNull StateContext context) { - sendAction(context, SenderAction.markSeen); + public Response markSeen(@NonNull StateContext context) { + return sendAction(context, SenderAction.markSeen); + } + + public Response sendAction(@NonNull StateContext context, @NonNull SenderAction senderAction) { + val recipientId = MessengerUtils.extractContextId(context.getContextId()); + Log.debug("Replying to {0} with a sender_action {1}", recipientId, senderAction.name()); + val messaging = new Messaging(new Recipient(recipientId), senderAction); + return reply(new Reply(this, context, messaging)); + } + + public Response uploadFile(@NonNull StateContext context, File file) { + return excecuteRequest(new FileReply(this, context, file)); + } + + public Response sendFile(@NonNull StateContext context, @NonNull String attachmentId, @NonNull Attachment.AttachmentType attachmentType) { + val recipientId = MessengerUtils.extractContextId(context.getContextId()); + val messaging = new Messaging( + new Recipient(recipientId), + new Message(new Attachment(attachmentType, new AttachmentIdPayload(attachmentId)))); + Log.debug("SENDING FILE TO: {0}", recipientId); + return reply(new Reply(this, context, messaging) + ); + } + + public Response sendFile(@NonNull StateContext context, @NonNull File file) { + var attachmentId = file.getAttachmentId(); //I did not use the custom extractContextId here, so this is a potential error place + if (StringUtils.isEmpty(attachmentId)) { + val response = uploadFile(context, file); + if (!(response instanceof SendResponse)) { + Log.error("Could not upload the file."); + return response; + } + attachmentId = ((SendResponse) response).getAttachmentId(); + } + + return sendFile(context, attachmentId, file.getAttachment().getType()); } - public void sendAction(@NonNull StateContext context, @NonNull SenderAction senderAction) { - val senderId = context.getContextId(); - Log.debug("Replying to {0} with a sender_action {1}", senderId, senderAction.name()); - val messaging = new Messaging(new Recipient(senderId), senderAction); - excecuteReply(new Reply(this, context, messaging)); + public Response reply(@NonNull StateContext context, @NonNull String text) { + return reply(context, new Message(text)); } - public void reply(@NonNull StateContext context, @NonNull String text) { - reply(context, new Message(text)); + public Response reply(@NonNull StateContext context, @NonNull Message message) { + val recipientId = MessengerUtils.extractContextId(context.getContextId()); + Log.debug("REPLYING TO: {0}", recipientId); + val messaging = new Messaging(new Recipient(recipientId), message); + return reply(new MessageReply(this, context, messaging)); } - public void reply(@NonNull StateContext context, @NonNull Message message) { - val senderId = context.getContextId(); - Log.debug("REPLYING TO: {0}", senderId); - val messaging = new Messaging(new Recipient(senderId), message); - excecuteReply(new MessageReply( - this, - context, - messaging)); + private Response reply(@NonNull Reply reply) { + return excecuteRequest(reply); } - private void excecuteReply(Reply reply) { - messagesSent++; - val result = reply.call().getResult(); + private Response excecuteRequest(JsonRestRequest request) { + val result = request.call().getResult(); if (result instanceof ApiResponse) { val apiResponse = (ApiResponse) result; + val responseBody = ((JsonElement) apiResponse.getBody()).getAsJsonObject(); + val status = apiResponse.getStatus(); + if (status < 200 || status > 299) { + Log.error("REPLY RESPONSE STATUS: {0} {1}\n BODY: {2}", apiResponse.getStatus(), apiResponse.getStatusText(), apiResponse.getBody().toString()); + if (responseBody.has("error")) { + val error = responseBody.get("error").getAsJsonObject(); + val code = error.has("code") ? error.get("code").getAsInt() : null; + val subcode = error.has("error_subcode") ? error.get("error_subcode").getAsInt() : null; + val fbtraceId = error.has("fbtrace_id") ? error.get("fbtrace_id").getAsString() : null; + val message = error.has("message") ? error.get("message").getAsString() : null; + return new ErrorResponse(status, code, subcode, fbtraceId, message); + } + return null; + } Log.debug("REPLY RESPONSE STATUS: {0} {1}\n BODY: {2}", apiResponse.getStatus(), apiResponse.getStatusText(), apiResponse.getBody().toString()); - if (apiResponse.getStatus() == 200) messagesSentSuccessfully++; - } else { - Log.debug("Unexpected reply result: {0}", result); + val recipientId = responseBody.has("recipient_id") ? responseBody.get("recipient_id").getAsString() : null; + val attachmentId = responseBody.has("attachment_id") ? responseBody.get("attachment_id").getAsString() : null; + val messageId = responseBody.has("message_id") ? responseBody.get("message_id").getAsString() : null; + return new SendResponse(status, recipientId, messageId, attachmentId); } + Log.error("Unexpected reply result: {0}", result); + return null; } public String getAppSecret() { @@ -100,6 +149,4 @@ public String getAppSecret() { public String getAccessToken() { return accessToken; } - - public int getMessagesSent() { return messagesSent; } } diff --git a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/MessengerUtils.java b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/MessengerUtils.java index 561fc94..2c610f5 100644 --- a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/MessengerUtils.java +++ b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/MessengerUtils.java @@ -13,6 +13,8 @@ public class MessengerUtils { public static final String VERIFY_TOKEN_KEY = MESSENGER_CONTEXT + "verify_token"; public static final String ACCESS_TOKEN_KEY = MESSENGER_CONTEXT + "access_token"; public static final String APP_SECRET_KEY = MESSENGER_CONTEXT + "app_secret"; + public static final String INTENT_FROM_POSTBACK = MESSENGER_CONTEXT + "intent_from_postback"; + public static final String INTENT_FROM_REACTION = MESSENGER_CONTEXT + "intent_from_reaction"; public static final String HANDLE_REACTIONS_KEY = MESSENGER_CONTEXT + "handle_reactions"; public static final String HANDLE_DELIVERIES_KEY = MESSENGER_CONTEXT + "handle_deliveries"; public static final String HANDLE_READ_KEY = MESSENGER_CONTEXT + "handle_read"; @@ -21,10 +23,18 @@ public class MessengerUtils { public static final String MESSAGE_ID_KEY = "mid"; public static final String MESSAGE_IDS_KEY = "mids"; public static final String WATERMARK_KEY = "watermark"; + public static final String POSTBACK_TITLE_KEY = "title"; + public static final String POSTBACK_PAYLOAD_KEY = "payload"; + public static final String POSTBACK_REFFERAL_REF_KEY = "refferal.ref"; + public static final String POSTBACK_REFFERAL_SOURCE_KEY = "refferal.source"; + public static final String POSTBACK_REFFERAL_TYPE_KEY = "refferal.type"; public static final String EMOJI_KEY = "emoji"; public static final String REACTION_KEY = "reaction"; public static final String WEBHOOK_URI = "/messenger/webhook"; public static final String SEND_API_URL = "https://graph.facebook.com/v8.0/me/messages"; + public static final String ATTACHMENT_UPLOAD_API_URL = "https://graph.facebook.com/v8.0/me/message_attachments"; + public static final String USE_REACTION_TEXT = "use_reaction_text"; + public static final String USE_TITLE_TEXT = "use_title_text"; public static String calculateRFC2104HMAC(String data, String key) throws NoSuchAlgorithmException, InvalidKeyException { @@ -33,4 +43,11 @@ public static String calculateRFC2104HMAC(String data, String key) throws NoSuch mac.init(signingKey); return new String(Hex.encodeHex(mac.doFinal(data.getBytes()))); } + + //If one adds dialogflow to the bot, dialogflow will add some prefix to the context id + public static String extractContextId(String contextIdWithDialogflow) { + String[] senderIdSplit = contextIdWithDialogflow.split("/"); //This removes the useless part and leaves only the id + return senderIdSplit[senderIdSplit.length - 1]; //A better solution is more than welcome + + } } diff --git a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/FileReply.java b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/FileReply.java new file mode 100644 index 0000000..f9f36ff --- /dev/null +++ b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/FileReply.java @@ -0,0 +1,56 @@ +package com.xatkit.plugins.messenger.platform.action; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.mashape.unirest.http.Unirest; +import com.mashape.unirest.request.HttpRequest; +import com.xatkit.execution.StateContext; +import com.xatkit.plugins.messenger.platform.MessengerPlatform; +import com.xatkit.plugins.messenger.platform.MessengerUtils; +import com.xatkit.plugins.messenger.platform.entity.File; +import com.xatkit.plugins.messenger.platform.entity.Message; +import com.xatkit.plugins.rest.platform.action.JsonRestRequest; +import lombok.val; +import org.apache.http.HttpHeaders; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +public class FileReply extends JsonRestRequest { + private static final Gson gson = new Gson(); + private final File file; + + /** + * Constructs a POST Json request with form data parameters + * + * @param platform the {@link MessengerPlatform} containing this action + * @param context the {@link StateContext} associated to this action + * @param file the information related to the file to be sent; + */ + public FileReply(MessengerPlatform platform, StateContext context, File file) { + super(platform, context, MethodKind.POST, MessengerUtils.ATTACHMENT_UPLOAD_API_URL, null, null, null, generateHeaders(platform), generateParams(file)); + this.file = file; + } + + private static Map generateHeaders(MessengerPlatform platform) { + val headers = new HashMap(); + headers.put(HttpHeaders.AUTHORIZATION, "Bearer " + platform.getAccessToken()); + return headers; + } + + private static Map generateParams(File file) { + Map params = new LinkedHashMap<>(); + params.put("message", gson.toJsonTree(new Message(file.getAttachment()))); + return params; + } + + protected HttpRequest buildRequest() { + return Unirest + .post(this.restEndpoint) + .headers(this.headers) + .fields(this.formParameters) + .field("filedata", file.getFile(), file.getMimeType()) + .getHttpRequest(); + } +} diff --git a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/MessageReply.java b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/MessageReply.java index 2b8c157..8bb229a 100644 --- a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/MessageReply.java +++ b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/MessageReply.java @@ -1,5 +1,6 @@ package com.xatkit.plugins.messenger.platform.action; +import com.mashape.unirest.http.exceptions.UnirestException; import com.xatkit.core.platform.action.RuntimeActionResult; import com.xatkit.execution.StateContext; import com.xatkit.plugins.messenger.platform.MessengerPlatform; @@ -7,13 +8,19 @@ import com.xatkit.plugins.messenger.platform.entity.SenderAction; import fr.inria.atlanmod.commons.log.Log; +import java.io.IOException; + import static com.xatkit.core.platform.action.RuntimeArtifactAction.DEFAULT_MESSAGE_DELAY; import static com.xatkit.core.platform.action.RuntimeArtifactAction.MESSAGE_DELAY_KEY; +import static java.util.Objects.nonNull; public class MessageReply extends Reply { private final int messageDelay; private final Messaging messaging; + private static final int IO_ERROR_RETRIES = 3; + private static final int RETRY_WAIT_TIME = 500; + /** * Constructs a POST Json request with a body parameter. * Delay is applied before sending the reply. @@ -28,14 +35,88 @@ public MessageReply(MessengerPlatform platform, StateContext context, Messaging this.messaging = messaging; } + + // this class should be refactored so that it extends RuntimeArtifactAction. + // this call method is a quick and dirty solution @Override public RuntimeActionResult call() { - if (messageDelay > 0) { - beforeDelay(); - waitMessageDelay(); - afterDelay(); - } - return super.call(); + Object computationResult = null; + Exception thrownException; + int attempts = 0; + long before = System.currentTimeMillis(); + + + /* + * We use a do-while here because the thrownException value is initialized with null, and we want to perform + * at least one iteration of the loop. If the thrownException value is still null after an iteration we can + * exit the loop: the underlying action computation finished without any exception. + */ + do { + /* + * Reset the thrownException, if we are retrying to send a artifact the previously stored exception is not + * required anymore: we can forget it and replace it with the potential new exception. + */ + thrownException = null; + attempts++; + if (attempts > 1) { + /* + * If this is not the first attempt we need to wait before sending again the artifact. The waiting + * time is equal to (iteration - 1) * RETRY_TIME: the second iteration will wait for RETRY_TIME, the + * third one for 2 * RETRY_TIME, etc. + */ + int waitTime = (attempts - 1) * RETRY_WAIT_TIME; + Log.info("Waiting {0} ms before trying to send the artifact again", waitTime); + try { + Thread.sleep(waitTime); + } catch (InterruptedException e1) { + /* + * Ignore the exception, the Thread has been interrupted but we can still compute the action. + */ + Log.warn("An error occurred while waiting to send the artifact, trying to send it right now", e1); + } + } + try { + if (messageDelay > 0) { + beforeDelay(); + waitMessageDelay(); + afterDelay(); + } + + try { + computationResult = this.compute(); + } catch (UnirestException e) { + throw (Exception) e.getCause(); + } + + } catch (IOException e) { + if (attempts < IO_ERROR_RETRIES + 1) { + Log.error("An {0} occurred when computing the action, trying to send the artifact again ({1}/{2})" + , e + .getClass().getSimpleName(), attempts, IO_ERROR_RETRIES); + } else { + Log.error("Could not compute the action: {0}", e.getClass().getSimpleName()); + } + /* + * Set the thrownException value, if the compute() method fails with an IOException every time we + * need to return an error message with it. + */ + thrownException = e; + } catch (Exception e) { + thrownException = e; + /* + * We caught a non-IO exception: an internal error occurred when computing the action. We assume that + * internal errors cannot be solved be recomputing the action, so we break and return the + * RuntimeActionResult directly. + */ + break; + } + /* + * Exit on IO_ERROR_RETRIES + 1: the first one is the standard execution, then we can retry + * IO_ERROR_RETRIES times. + */ + } while (nonNull(thrownException) && attempts < IO_ERROR_RETRIES + 1); + long after = System.currentTimeMillis(); + return new RuntimeActionResult(computationResult, thrownException, (after - before)); } private void waitMessageDelay() { diff --git a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/Reply.java b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/Reply.java index 152d639..679b75c 100644 --- a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/Reply.java +++ b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/action/Reply.java @@ -6,6 +6,7 @@ import com.xatkit.plugins.messenger.platform.MessengerUtils; import com.xatkit.plugins.messenger.platform.entity.Messaging; import com.xatkit.plugins.rest.platform.action.PostJsonRequestWithBody; +import fr.inria.atlanmod.commons.log.Log; import lombok.val; import org.apache.http.HttpHeaders; diff --git a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/Attachment.java b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/Attachment.java index bfaeed8..2c18205 100644 --- a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/Attachment.java +++ b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/Attachment.java @@ -1,6 +1,8 @@ package com.xatkit.plugins.messenger.platform.entity; import com.google.gson.annotations.SerializedName; +import com.xatkit.plugins.messenger.platform.entity.payloads.Payload; +import com.xatkit.plugins.messenger.platform.entity.payloads.TemplatePayload; import lombok.Getter; public class Attachment { @@ -16,11 +18,19 @@ public enum AttachmentType { @SerializedName("video") video, @SerializedName("image") - image + image, + @SerializedName("template") + template, + @SerializedName("file") + file } public Attachment(final AttachmentType type, final Payload payload) { this.type = type; this.payload = payload; } + + public Attachment(final TemplatePayload payload) { + this(AttachmentType.template, payload); + } } diff --git a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/File.java b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/File.java new file mode 100644 index 0000000..9de2ad8 --- /dev/null +++ b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/File.java @@ -0,0 +1,31 @@ +package com.xatkit.plugins.messenger.platform.entity; + +import com.xatkit.plugins.messenger.platform.entity.payloads.FilePayload; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; + +import java.io.IOException; +import java.nio.file.Files; + +public class File { + @Getter + private final Attachment attachment; + @Getter + private final java.io.File file; + @Getter + private final String mimeType; + @Getter + @Setter + private String attachmentId; + + public File(@NonNull Attachment.AttachmentType attachmentType, @NonNull java.io.File file) throws IOException { + this(attachmentType, file, Files.probeContentType(file.toPath())); + } + + public File(@NonNull Attachment.AttachmentType attachmentType, @NonNull java.io.File file, @NonNull String mimeType) { + this.mimeType = mimeType; + this.file = file; + this.attachment = new Attachment(attachmentType, new FilePayload(true)); + } +} diff --git a/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/GenericElement.java b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/GenericElement.java new file mode 100644 index 0000000..fb4b3a5 --- /dev/null +++ b/runtime/src/main/java/com/xatkit/plugins/messenger/platform/entity/GenericElement.java @@ -0,0 +1,32 @@ +package com.xatkit.plugins.messenger.platform.entity; + +import com.google.gson.annotations.SerializedName; +import com.xatkit.plugins.messenger.platform.entity.buttons.Button; +import com.xatkit.plugins.messenger.platform.entity.buttons.DefaultActionButton; +import lombok.Getter; + +import java.util.List; + +//TODO Add javadoc once Media templates (which also use Element, but with different fields) are done. +public class GenericElement { + @Getter + private final String title; //Mandatory along with 1 more field | 80 character limit (the rest will get shortened with ...) + @Getter + private final String subtitle; //80 character limit (the rest will get shortened with ...) + @SerializedName(value = "image_url") + @Getter + private final String imageURL; + @SerializedName(value = "default_action") + @Getter + private final DefaultActionButton defaultActionButton; + @Getter + private final List