Skip to content

Commit

Permalink
Merge pull request #12 from facebookincubator/hunter/WA
Browse files Browse the repository at this point in the history
Introduce Whatsapp Handler
  • Loading branch information
hunterjackson authored Sep 29, 2023
2 parents 72e7001 + 0212b38 commit 5b73d57
Show file tree
Hide file tree
Showing 47 changed files with 2,881 additions and 125 deletions.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>5.6.1</version>
<version>5.6.2</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/meta/chatbridge/Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ private void execute(ThreadState<T> thread) {
// we log in the handler where we have the body context
// TODO: create transactional store add
// TODO: implement retry with exponential backoff
LOGGER.error("an error occurred while attempting to respond", e);
}
}
}
64 changes: 10 additions & 54 deletions src/main/java/com/meta/chatbridge/message/FBMessageHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,15 @@
import com.meta.chatbridge.Identifier;
import io.javalin.http.BadRequestResponse;
import io.javalin.http.Context;
import io.javalin.http.ForbiddenResponse;
import io.javalin.http.HandlerType;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Stream;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.hc.client5.http.fluent.Request;
import org.apache.hc.client5.http.fluent.Response;
import org.apache.hc.client5.http.utils.Hex;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.net.URIBuilder;
Expand All @@ -46,6 +38,8 @@ public class FBMessageHandler implements MessageHandler<FBMessage> {
private static final String API_VERSION = "v17.0";
private static final JsonMapper MAPPER = new JsonMapper();
private static final Logger LOGGER = LoggerFactory.getLogger(FBMessageHandler.class);
private static final TextChunker CHUNKER = TextChunker.standard(2000);

private final String verifyToken;
private final String appSecret;

Expand Down Expand Up @@ -80,13 +74,6 @@ public FBMessageHandler(String verifyToken, String pageAccessToken, String appSe
this.accessToken = config.pageAccessToken();
}

private static Stream<String> textChunker(String text, String regexSeparator) {
if (text.length() > 2000) {
return Arrays.stream(text.split(regexSeparator)).map(String::strip);
}
return Stream.of(text);
}

@Override
public List<FBMessage> processRequest(Context ctx) {
try {
Expand All @@ -97,7 +84,6 @@ public List<FBMessage> processRequest(Context ctx) {
case POST -> {
return postHandler(ctx);
}
default -> throw new UnsupportedOperationException("Only accepting get and post methods");
}
} catch (JsonProcessingException | NullPointerException e) {
LOGGER
Expand All @@ -115,46 +101,23 @@ public List<FBMessage> processRequest(Context ctx) {
LOGGER.error(e.getMessage(), e);
throw new RuntimeException(e);
}
throw new UnsupportedOperationException("Only accepting get and post methods");
}

private List<FBMessage> getHandler(Context ctx) {
ctx.queryParamAsClass("hub.mode", String.class)
.check(v -> v.equals("subscribe"), "hub.mode must be subscribe");
ctx.queryParamAsClass("hub.verify_token", String.class)
.check(v -> v.equals(verifyToken), "verify_token is incorrect");
int challenge = ctx.queryParamAsClass("hub.challenge", int.class).get();
ctx.result(String.valueOf(challenge));
MetaHandlerUtils.subscriptionVerification(ctx, verifyToken);
LOGGER.debug("Meta verified callback url successfully");
return Collections.emptyList();
}

@TestOnly
String hmac(String body) {
Mac sha256HMAC;
SecretKeySpec secretKey;
try {
sha256HMAC = Mac.getInstance("HmacSHA256");
secretKey = new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256HMAC.init(secretKey);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException(e); // Algorithms guaranteed to exist
}
return Hex.encodeHexString(sha256HMAC.doFinal(body.getBytes(StandardCharsets.UTF_8)));
// TODO: refactor test so we don't need this
return MetaHandlerUtils.hmac(body, appSecret);
}

private List<FBMessage> postHandler(Context ctx) throws JsonProcessingException {
// https://developers.facebook.com/docs/messenger-platform/reference/webhook-events

ctx.headerAsClass("X-Hub-Signature-256", String.class)
.check(
h -> {
String[] hashParts = h.strip().split("=");
if (hashParts.length != 2) {
return false;
}
String calculatedHmac = hmac(ctx.body());
return hashParts[1].equals(calculatedHmac);
},
"X-Hub-Signature-256 could not be validated")
.getOrThrow(ignored -> new ForbiddenResponse("X-Hub-Signature-256 could not be validated"));
MetaHandlerUtils.postHeaderValidator(ctx, appSecret);

String bodyString = ctx.body();
JsonNode body = MAPPER.readTree(bodyString);
Expand Down Expand Up @@ -228,14 +191,7 @@ private List<FBMessage> postHandler(Context ctx) throws JsonProcessingException

@Override
public void respond(FBMessage message) throws IOException {
List<String> chunkedText =
Stream.of(message.message().strip())
.flatMap(m -> textChunker(m, "\n\n\n+"))
.flatMap(m -> textChunker(m, "\n\n"))
.flatMap(m -> textChunker(m, "\n"))
.flatMap(m -> textChunker(m, "\\. +"))
.flatMap(m -> textChunker(m, " +"))
.toList();
List<String> chunkedText = CHUNKER.chunks(message.message()).toList();
for (String text : chunkedText) {
send(text, message.recipientId(), message.senderId());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = FBMessengerConfig.class, name = "messenger"),
@JsonSubTypes.Type(value = WAMessengerConfig.class, name = "whatsapp"),
})
public interface HandlerConfig {
String name();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ public interface MessageFactory<T extends Message> {
Map<Class<? extends Message>, MessageFactory<? extends Message>> FACTORY_MAP =
Stream.<FactoryContainer<?>>of(
new FactoryContainer<>(
FBMessage.class, (t, m, si, ri, ii, r) -> new FBMessage(t, ii, si, ri, m, r)))
FBMessage.class, (t, m, si, ri, ii, r) -> new FBMessage(t, ii, si, ri, m, r)),
new FactoryContainer<>(
WAMessage.class, (t, m, si, ri, ii, r) -> new WAMessage(t, ii, si, ri, m, r)))
.collect(
Collectors.toUnmodifiableMap(FactoryContainer::clazz, FactoryContainer::factory));

Expand Down
68 changes: 68 additions & 0 deletions src/main/java/com/meta/chatbridge/message/MetaHandlerUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.meta.chatbridge.message;

import io.javalin.http.Context;
import io.javalin.http.ForbiddenResponse;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.hc.client5.http.utils.Hex;

class MetaHandlerUtils {
static void subscriptionVerification(Context ctx, String verifyToken) {
ctx.queryParamAsClass("hub.mode", String.class)
.check(v -> v.equals("subscribe"), "hub.mode must be subscribe");
ctx.queryParamAsClass("hub.verify_token", String.class)
.check(v -> v.equals(verifyToken), "verify_token is incorrect");
int challenge = ctx.queryParamAsClass("hub.challenge", int.class).get();
ctx.result(String.valueOf(challenge));
}

static String hmac(String body, String appSecret) {
Mac sha256HMAC;
SecretKeySpec secretKey;
try {
sha256HMAC = Mac.getInstance("HmacSHA256");
secretKey = new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256HMAC.init(secretKey);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException(e); // Algorithms guaranteed to exist
}
return Hex.encodeHexString(sha256HMAC.doFinal(body.getBytes(StandardCharsets.UTF_8)));
}

/**
* Use the appSecret to validate that the value set in X-Hub-Signature-256 is correct. Throws a
* Javalin {@link io.javalin.validation.ValidationError} if the header is not valid
*
* <p><a
* href="https://developers.facebook.com/docs/messenger-platform/reference/webhook-events">messenger
* documentation on this process</a>
*
* @param ctx Javalin context corresponding to this post request
* @param appSecret app secret corresponding to this app
*/
static void postHeaderValidator(Context ctx, String appSecret) {
ctx.headerAsClass("X-Hub-Signature-256", String.class)
.check(
h -> {
String[] hashParts = h.strip().split("=");
if (hashParts.length != 2) {
return false;
}
String calculatedHmac = hmac(ctx.body(), appSecret);
return hashParts[1].equals(calculatedHmac);
},
"X-Hub-Signature-256 could not be validated")
.getOrThrow(ignored -> new ForbiddenResponse("X-Hub-Signature-256 could not be validated"));
}
}
106 changes: 106 additions & 0 deletions src/main/java/com/meta/chatbridge/message/TextChunker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.meta.chatbridge.message;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.checkerframework.common.reflection.qual.NewInstance;

/**
* Splits texts into 'chunks' of test with less than or equal to the requested number of characters.
* The text block is split first on the regex separators, in the defined order, each separator is
* only used to split the chunk if the chunk exceeds the defined maximum numbers of characters per
* chunk. If all separators are exhausted and a chunk still exceeds the maximum number of characters
* allowed it is split without regard for any separator into chunks less than or equal to the
* maximum character size.
*/
public class TextChunker {

private final int maxCharsPerChunk;
private final List<Pattern> regex;

private TextChunker(int maxCharsPerChunk, List<Pattern> regex) {
Preconditions.checkArgument(maxCharsPerChunk > 0);
this.maxCharsPerChunk = maxCharsPerChunk;
this.regex = regex;
}

public static TextChunker from(int maxCharsPerChunk) {
return new TextChunker(maxCharsPerChunk, Collections.emptyList());
}

/**
* A separator with sensible separators, the separators are applied in this order.
*
* <pre>{@code
* from(maxCharsPerChunk)
* .withSeparator("\n\n\n+")
* .withSeparator("\n\n")
* .withSeparator("\n")
* .withSeparator("\\. +") // any period, including the following whitespaces
* .withSeparator("\s\s+") // any set of two or more whitespace characters
* .withSeparator(" +"); // any set of one or more whitespace spaces
* }</pre>
*
* @return stream of text chunks less than or equal to the maxCharsPerChunk
*/
public static TextChunker standard(int maxCharsPerChunk) {
return from(maxCharsPerChunk)
.withSeparator("\n\n\n+")
.withSeparator("\n\n")
.withSeparator("\n")
.withSeparator("\\. +") // any period, including the following whitespaces
.withSeparator("\s\s+") // any set of two or more whitespace characters
.withSeparator(" +"); // any set of one or more whitespace spaces
}

public @NewInstance TextChunker withSeparator(String regex) {
return withSeparator(Pattern.compile(regex));
}

public @NewInstance TextChunker withSeparator(Pattern regex) {
ImmutableList<Pattern> newRegex =
ImmutableList.<Pattern>builder().addAll(this.regex).add(regex).build();

return new TextChunker(maxCharsPerChunk, newRegex);
}

private Stream<String> breaker(String text) {
ArrayList<String> out = new ArrayList<>((text.length() / maxCharsPerChunk) + 1);
while (text.length() > maxCharsPerChunk) {
out.add(text.substring(0, maxCharsPerChunk));
text = text.substring(maxCharsPerChunk);
}
out.add(text);
return out.stream();
}

private Stream<String> chunker(String text, Pattern regex) {
if (text.length() > maxCharsPerChunk) {
return Arrays.stream(regex.split(text, 0));
}
return Stream.of(text);
}

public Stream<String> chunks(String text) {
Stream<String> stream = Stream.of(text.strip());
for (Pattern r : regex) {
stream = stream.flatMap(t -> chunker(t, r));
}
stream = stream.flatMap(this::breaker);

return stream;
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/meta/chatbridge/message/WAMessage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.meta.chatbridge.message;

import com.meta.chatbridge.Identifier;
import java.time.Instant;

public record WAMessage(
Instant timestamp,
Identifier instanceId,
Identifier senderId,
Identifier recipientId,
String message,
Role role)
implements Message {}
Loading

0 comments on commit 5b73d57

Please sign in to comment.