Skip to content

Commit

Permalink
Merge pull request #190 from ibi-group/notify-at-leadtime
Browse files Browse the repository at this point in the history
Trip monitoring: Notify at leadtime
  • Loading branch information
binh-dam-ibigroup authored Nov 9, 2023
2 parents 1abd925 + df3a515 commit a685f63
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 110 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ public class MonitoredTrip extends Model {

public JourneyState journeyState = new JourneyState();

/**
* Whether to notify the user when the monitoring of this trip starts.
*/
public boolean notifyAtLeadingInterval = true;

public MonitoredTrip() {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ public static TripMonitorNotification createItineraryNotFoundNotification(
return notification;
}

/**
* Creates an initial reminder of the itinerary monitoring.
*/
public static TripMonitorNotification createInitialReminderNotification(MonitoredTrip trip) {
TripMonitorNotification notification = new TripMonitorNotification();
notification.type = NotificationType.INITIAL_REMINDER;
// TODO: i18n and add itinerary details.
notification.body = String.format("Reminder for your upcoming trip at %s. We will let you know if anything changes.", trip.tripTime);
return notification;
}

private static String bodyFromAlerts(
Set<LocalizedAlert> previousAlerts,
Set<LocalizedAlert> resolvedAlerts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import org.opentripplanner.middleware.models.OtpUser;
import org.opentripplanner.middleware.models.TripMonitorNotification;
import org.opentripplanner.middleware.otp.OtpVersion;
import org.opentripplanner.middleware.otp.response.Leg;
import org.opentripplanner.middleware.tripmonitor.TripStatus;
import org.opentripplanner.middleware.otp.OtpDispatcher;
import org.opentripplanner.middleware.otp.OtpDispatcherResponse;
Expand All @@ -23,7 +22,6 @@
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import java.net.URISyntaxException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
Expand Down Expand Up @@ -121,6 +119,8 @@ private void doRun() {
return;
}

// Initial reminder notification, if needed
addInitialReminderIfNeeded();
// Check monitored trip.
runCheckLogic();
// Send notifications to user. This should happen before updating the journey state so that we can check the
Expand All @@ -130,6 +130,31 @@ private void doRun() {
updateMonitoredTrip();
}

/**
* Determine whether to send an initial "reminder" notification through the user's enabled notification channels.
* The initial reminder is sent the first time a check for a trip that is active today
* occurs within the monitoring lead time.
*/
private void addInitialReminderIfNeeded() {
boolean isFirstTimeCheckWithinLeadMonitoringTime = isFirstTimeCheckWithinLeadMonitoringTime();
boolean userWantsInitialReminder = !trip.snoozed && trip.notifyAtLeadingInterval;

if (!trip.isInactive() && isFirstTimeCheckWithinLeadMonitoringTime && userWantsInitialReminder) {
enqueueNotification(
TripMonitorNotification.createInitialReminderNotification(trip)
);
}
}

/**
* @return true if the previous check was outside the monitoring lead time and this check is inside.
*/
private boolean isFirstTimeCheckWithinLeadMonitoringTime() {
long minutesSinceLastCheck = getMinutesSinceLastCheck();
long minutesUntilTrip = getMinutesUntilTrip();
return minutesUntilTrip <= trip.leadTimeInMinutes && minutesUntilTrip + minutesSinceLastCheck > trip.leadTimeInMinutes;
}

private void runCheckLogic() {
// Make a request to OTP and find the matching itinerary. If there was an error or the matching itinerary was
// not found or the trip is no longer active, don't run the other checks.
Expand Down Expand Up @@ -403,6 +428,7 @@ private void sendNotifications() {
}
Map<String, Object> templateData = Map.of(
"tripId", trip.id,
"tripName", trip.tripName,
"notifications", notifications.stream()
.map(notification -> notification.body)
.collect(Collectors.toList())
Expand Down Expand Up @@ -440,7 +466,7 @@ private boolean sendSMS(OtpUser otpUser, Map<String, Object> data) {
* Send push notification.
*/
private boolean sendPush(OtpUser otpUser, Map<String, Object> data) {
return NotificationUtils.sendPush(otpUser, "MonitoredTripText.ftl", data) != null;
return NotificationUtils.sendPush(otpUser, "MonitoredTripPush.ftl", data) != null;
}

/**
Expand All @@ -464,6 +490,22 @@ private void enqueueNotification(TripMonitorNotification ...tripMonitorNotificat
}
}

private long getMinutesSinceLastCheck() {
long millisSinceLastCheck = DateTimeUtils.currentTimeMillis() - previousJourneyState.lastCheckedEpochMillis;
return TimeUnit.MILLISECONDS.toMinutes(millisSinceLastCheck);
}

private long getMinutesUntilTrip() {
// get the configured timezone that OTP is using to parse dates and times
ZoneId targetZoneId = DateTimeUtils.getOtpZoneId();
Instant tripStartInstant = matchingItinerary.startTime.toInstant();

// Get current time and trip time (with the time offset to today) for comparison.
ZonedDateTime now = DateTimeUtils.nowAsZonedDateTime(targetZoneId);

return (tripStartInstant.getEpochSecond() - now.toEpochSecond()) / 60;
}

/**
* Determine whether to skip checking the monitored trip at this instant. The decision on whether to skip the check
* takes into account the current time, the lead time prior to the itinerary start and the last time that the trip
Expand Down Expand Up @@ -589,10 +631,9 @@ public boolean shouldSkipMonitoredTripCheck() throws Exception {
LOG.info("Trip starts at {} (now={})", tripStartInstant.toString(), now.toString());

// If last check was more than an hour ago and trip doesn't occur until an hour from now, check trip.
long millisSinceLastCheck = DateTimeUtils.currentTimeMillis() - previousJourneyState.lastCheckedEpochMillis;
long minutesSinceLastCheck = TimeUnit.MILLISECONDS.toMinutes(millisSinceLastCheck);
long minutesSinceLastCheck = getMinutesSinceLastCheck();
LOG.info("{} minutes since last checking trip", minutesSinceLastCheck);
long minutesUntilTrip = (tripStartInstant.getEpochSecond() - now.toEpochSecond()) / 60;
long minutesUntilTrip = getMinutesUntilTrip();
LOG.info("Trip starts in {} minutes", minutesUntilTrip);
// skip check if the time until the next trip starts is longer than the requested lead time
if (minutesUntilTrip > trip.leadTimeInMinutes) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ public enum NotificationType {
ARRIVAL_DELAY,
ITINERARY_CHANGED, // TODO
ALERT_FOUND,
ITINERARY_NOT_FOUND
ITINERARY_NOT_FOUND,
INITIAL_REMINDER
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
import org.opentripplanner.middleware.bugsnag.BugsnagReporter;
import org.opentripplanner.middleware.models.AdminUser;
import org.opentripplanner.middleware.models.OtpUser;
import org.opentripplanner.middleware.utils.HttpUtils;
import org.opentripplanner.middleware.utils.JsonUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -44,6 +42,17 @@ public class NotificationUtils {
private static final String PUSH_API_KEY = getConfigPropertyAsText("PUSH_API_KEY");
private static final String PUSH_API_URL = getConfigPropertyAsText("PUSH_API_URL");

/**
* Although SMS are 160 characters long and Twilio supports sending up to 1600 characters,
* they don't recommend sending more than 320 characters in a single "request"
* to not inundate users with long messages and to reduce cost
* (messages above 160 characters are split into multiple SMS that are billed individually).
* See https://support.twilio.com/hc/en-us/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
*/
private static final int SMS_MAX_LENGTH = 320;
/** Lowest permitted push message length between Android (240) and iOS (178). */
private static final int PUSH_MESSAGE_MAX_LENGTH = 178;

/**
* @param otpUser target user
* @param textTemplate template to use for email in text format
Expand Down Expand Up @@ -71,8 +80,18 @@ public static String sendPush(OtpUser otpUser, String textTemplate, Object templ
*/
static String sendPush(String toUser, String body) {
try {
var jsonBody = "{\"user\":\"" + toUser + "\",\"message\":\"" + body + "\"}";
Map<String, String> headers = Map.of("Accept", "application/json");
var jsonBody = String.format(
"{\"user\":\"%s\",\"message\":\"%s\"}",
toUser,
body
// Escape carriage returns and trim message length (iOS limitation).
.replace("\n", "\\n")
.substring(0, PUSH_MESSAGE_MAX_LENGTH - 1)
);
Map<String, String> headers = Map.of(
"Accept", "application/json",
"Content-Type", "application/json"
);
var httpResponse = HttpUtils.httpRequestRawResponse(
URI.create(PUSH_API_URL + "/notification/publish?api_key=" + PUSH_API_KEY),
1000,
Expand Down Expand Up @@ -131,7 +150,8 @@ public static String sendSMS(String toPhone, String body) {
Message message = Message.creator(
toPhoneNumber,
fromPhoneNumber,
body
// Trim body to max message length
body.substring(0, SMS_MAX_LENGTH - 1)
).create();
LOG.debug("SMS ({}) sent successfully", message.getSid());
return message.getSid();
Expand Down
Loading

0 comments on commit a685f63

Please sign in to comment.