Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Messaging abstraction updates #134

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
</developers>

<dependencies>
<!-- https://mvnrepository.com/artifact/com.messagebird/messagebird-api -->
<dependency>
<groupId>com.messagebird</groupId>
<artifactId>messagebird-api</artifactId>
<version>6.0.0</version>
</dependency>
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-55</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.impactupgrade.nucleus.client;

import com.impactupgrade.nucleus.environment.Environment;
import com.impactupgrade.nucleus.util.HttpClient;

import com.messagebird.MessageBirdClient;
import com.messagebird.MessageBirdService;
import com.messagebird.MessageBirdServiceImpl;
import com.messagebird.objects.Message;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import javax.ws.rs.core.Response;
import javax.ws.rs.core.MediaType;

import static com.impactupgrade.nucleus.util.HttpClient.post;

public class MessageBirdSMSClient {
private static final Logger log = LogManager.getLogger(MessageBirdSMSClient.class);
private String accessKey;
private String smsWorkspaceId;
private String smsChannelId;
protected Environment env;
private MessageBirdService messageBirdService;
private MessageBirdClient messageBirdClient;
private String MESSAGE_BIRD_ENGAGEMENT_API_URL = "https://nest.messagebird.com/";

public MessageBirdSMSClient(Environment env) {
this.env = env;
this.accessKey = env.getConfig().messageBird.accessKey;
this.smsWorkspaceId = env.getConfig().messageBird.SMSWorkspaceId;
this.smsChannelId = env.getConfig().messageBird.SMSChannelId;
this.messageBirdService = new MessageBirdServiceImpl(accessKey);
this.messageBirdClient = new MessageBirdClient(messageBirdService);
}

// using the standard API & Java SDK
public void sendMessage(String to, String from, String body) {
try {
Message message = new Message(from, body, to);
messageBirdClient.sendMessage(message);
log.info("Message sent successfully to " + to);
} catch (Exception e) {
log.error("Error sending message: " + e.getMessage());
}
}

// using the new engagement platform direct API call
public Response sendMessageEngagement(String to, String from, String body) {
return post(
MESSAGE_BIRD_ENGAGEMENT_API_URL + "/workspaces/" + smsWorkspaceId + "/channels/"+ smsChannelId +"/messages",
createSMSMessageBody(to, body),
MediaType.APPLICATION_JSON,
headers()
);
}

private HttpClient.HeaderBuilder headers(){
return HttpClient.HeaderBuilder.builder().header("Authorization","AccessKey " + accessKey);
}

private String createSMSMessageBody(String to, String body) {
return "{\"receiver\": {\"contacts\": [{\"identifierValue\": \"" + to + "\"}]}, " +
"\"body\": {\"type\": \"text\", \"text\": {\"text\": \"" + body + "\"}}}";
}
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package com.impactupgrade.nucleus.controller;

import com.google.common.base.Strings;
import com.impactupgrade.nucleus.entity.JobType;
import com.impactupgrade.nucleus.environment.Environment;
import com.impactupgrade.nucleus.environment.EnvironmentFactory;
import com.impactupgrade.nucleus.model.ContactSearch;
import com.impactupgrade.nucleus.model.CrmContact;
import com.impactupgrade.nucleus.model.CrmOpportunity;
import com.impactupgrade.nucleus.util.Utils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Consumes;
import javax.ws.rs.FormParam;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Form;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;

import static com.impactupgrade.nucleus.util.Utils.noWhitespace;
import static com.impactupgrade.nucleus.util.Utils.trim;

@Path("/messaging")
Levi-Lehman marked this conversation as resolved.
Show resolved Hide resolved
public class MessagingController {
private static final Logger log = LogManager.getLogger(MessagingController.class);

protected final EnvironmentFactory envFactory;
public MessagingController(EnvironmentFactory envFactory) {
this.envFactory = envFactory;
}

/**
* This webhook is to be used by SMS flows for more complex
* interactions. Try to make use of the standard form params whenever possible to maintain the overlap!
*/
@Path("/inbound/sms/signup")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_XML)
public Response inboundSignup(
@FormParam("From") String from,
@FormParam("FirstName") String _firstName,
@FormParam("LastName") String _lastName,
@FormParam("FullName") String fullName,
@FormParam("Email") String _email,
@FormParam("EmailOptIn") String emailOptIn,
@FormParam("SmsOptIn") String smsOptIn,
@FormParam("Language") String _language,
@FormParam("ListId") String _listId,
@FormParam("HubSpotListId") @Deprecated Long hsListId,
@FormParam("CampaignId") String campaignId,
@FormParam("OpportunityName") String opportunityName,
@FormParam("OpportunityRecordTypeId") String opportunityRecordTypeId,
@FormParam("OpportunityOwnerId") String opportunityOwnerId,
@FormParam("OpportunityNotes") String opportunityNotes,
@FormParam("nucleus-username") String nucleusUsername,
@Context HttpServletRequest request
) throws Exception {
log.info("from={} firstName={} lastName={} fullName={} email={} emailOptIn={} smsOptIn={} language={} listId={} hsListId={} campaignId={} opportunityName={} opportunityRecordTypeId={} opportunityOwnerId={} opportunityNotes={}",
from, _firstName, _lastName, fullName, _email, emailOptIn, smsOptIn, _language, _listId, hsListId, campaignId, opportunityName, opportunityRecordTypeId, opportunityOwnerId, opportunityNotes);
Environment env = envFactory.init(request);

_firstName = trim(_firstName);
_lastName = trim(_lastName);
fullName = trim(fullName);
final String email = noWhitespace(_email);
final String language = noWhitespace(_language);

String firstName;
String lastName;
if (!Strings.isNullOrEmpty(fullName)) {
String[] split = Utils.fullNameToFirstLast(fullName);
firstName = split[0];
lastName = split[1];
} else {
firstName = _firstName;
lastName = _lastName;
}

String listId;
if (hsListId != null && hsListId > 0) {
listId = hsListId + "";
} else {
listId = _listId;
}
Runnable thread = () -> {
try {
String jobName = "SMS Flow";
env.startJobLog(JobType.EVENT, null, jobName, env.textingService().name());
CrmContact crmContact = env.messagingService().processSignup(
from,
firstName,
lastName,
email,
emailOptIn,
smsOptIn,
language,
campaignId,
listId
);

// avoid the insertOpportunity call unless we're actually creating a non-donation opportunity
CrmOpportunity crmOpportunity = new CrmOpportunity();
crmOpportunity.contact.id = crmContact.id;

if (!Strings.isNullOrEmpty(opportunityName)) {
crmOpportunity.name = opportunityName;
crmOpportunity.recordTypeId = opportunityRecordTypeId;
crmOpportunity.ownerId = opportunityOwnerId;
crmOpportunity.campaignId = campaignId;
crmOpportunity.description = opportunityNotes;
env.messagingCrmService().insertOpportunity(crmOpportunity);
env.endJobLog(jobName);
}
} catch (Exception e) {
log.warn("inbound SMS signup failed", e);
env.logJobError(e.getMessage());
}
};
new Thread(thread).start();

//TODO removed OG Twilio response, revisit after sorting out MB
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need to shake up the whole flow to make this happen.

  • Remove the use of a new thread in this whole endpoint.
  • Break apart the processing code in MessagingService. Spin up a new thread there instead.
  • Allow a vender-specific response to be created and bubbled up from the underlying segment service. Something like TwilioService (whatever we call it) -> MessagingService -> MessagingController.

However, you receive bonus points for the use of "OG".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Started implementing this and am getting a bit lost in the separation of everything. It seems to me like there is nothing super Twilio-specific besides how the response is being generated. Do we just need something like
textingService.buildResponse() for now? Could be misunderstanding

Copy link
Contributor

@Levi-Lehman Levi-Lehman May 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume we will have more complex needs as we get deeper into MessageBird flows though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A buildResponse() would work for the default "yup I got it" responses, but we might need something else more specific for certain circumstances.

In Twilio land, webhooks out to external services are expected to respond back with TwiML, which is just an XML schema used to tell Twilio what to do next. So in some situations, we might return a reply with message "foobar". It's more useful for things like decision trees being handled by external services. But in our cases, often we're simply returning back empty TwiML since there's nothing extra to do.

That part is definitely Twilio-specific -- not sure if we'll need something similar in MB land.

return Response.ok().build();
}

private static final List<String> STOP_WORDS = List.of("STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT");

/**
* This webhook serves as a more generic catch-all endpoint for inbound messages from SMS Services.
*/
@Path("/inbound/sms/webhook")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_XML)
public Response inboundWebhook(
Form rawFormData,
@Context HttpServletRequest request
) throws Exception {
Environment env = envFactory.init(request);

MultivaluedMap<String, String> smsData = rawFormData.asMap();
log.info(smsData.entrySet().stream().map(e -> e.getKey() + "=" + String.join(",", e.getValue())).collect(Collectors.joining(" ")));

String from = smsData.get("From").get(0);
if (smsData.containsKey("Body")) {
String body = smsData.get("Body").get(0).trim();
// prevent opt-out messages, like "STOP", from polluting the notifications
if (!STOP_WORDS.contains(body.toUpperCase(Locale.ROOT))) {
String jobName = "SMS Inbound";
env.startJobLog(JobType.EVENT, null, jobName, env.textingService().name());
String targetId = env.messagingCrmService().searchContacts(ContactSearch.byPhone(from)).getSingleResult().map(c -> c.id).orElse(null);
env.notificationService().sendNotification(
"Text Message Received",
"Text message received from " + from + ": " + body,
targetId,
"sms:inbound-default"
);
env.endJobLog(jobName);
}
}
//TODO removed OG Twilio response, revisit after sorting out MB
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

return Response.ok().build();
}

}
Loading