Not logged in.  Login/Logout/Register | List snippets | | Create snippet | Upload image | Upload data

620
LINES

< > BotCompany Repo | #1028621 - Booking [Include for BookBetterNow]

JavaX fragment (include) [tags: use-pretranspiled]

import com.google.api.services.calendar.model.EventDateTime;

static L<LongRange> testModeEventRanges() {
  ret ll(
    longRange(
      parseYMDHMS("2020/07/03 9:00:00", localTimeZone()),
      parseYMDHMS("2020/07/03 10:30:00", localTimeZone())),
      
    // full day booked
    longRange(
      parseYMDHMS("2020/07/06 9:00:00", localTimeZone()),
      parseYMDHMS("2020/07/06 20:00:00", localTimeZone()))
      );
}

static long testModeDate() {
  ret parseYMDHMS("2020/07/02 17:00:00", localTimeZone());
}

static Map<Int, L<IntRange>> testModeBusinessHours() {
  L<IntRange> r = parseBusinessHours("9-5");
  ret litmap(2 := r, 3 := r, 4 := r, 5 := r, 6 := r);
}

concept BookingConfig {
  bool bookingOpen = true;
  S googleCalendarAccessToken, googleCalendarRefreshToken;
  S twilioAccountSID, twilioAuthToken, twilioPhoneNr;
  S ownerPhoneNr;
  S eventKeyword = "[SALES]";
  S businessHoursSunday, businessHoursMonday, businessHoursTuesday, businessHoursWednesday,
    businessHoursThursday, businessHoursFriday, businessHoursSaturday;
  //S vacations; // free-form date description
  S businessName;
  S appointmentName;
  int appointmentDuration; // minutes
  int appointmentGranularity; // minutes
  int appointmentMaxLead; // days
  int dateSuggestionsInGreeting, dateSuggestionsOnFail = 2;
  S smsTemplate;
  
  Map<Int, S> businessHourStrings() {
    ret pairsToTreeMap(ll(
      pair(1, businessHoursSunday),
      pair(2, businessHoursMonday),
      pair(3, businessHoursTuesday),
      pair(4, businessHoursWednesday),
      pair(5, businessHoursThursday),
      pair(6, businessHoursFriday),
      pair(7, businessHoursSaturday)));
  }
}

static Booking booking;

sclass Booking {
  new GoogleAccess googleAccess;
  Calendar googleCalendarService;
  BookingConfig conf;
  bool anyTimeSlots = true;

  *() {
    indexSingletonConcept BookingConfig();
    conf = uniq BookingConfig();
    initGoogleCalendar();
    initTwilio();
    // We check slots on every bot greeting so not necessary
    //doEveryHourAndNow(r checkIfAnyTimeSlots);
    print("Booking inited");
  }
  
  O html(S uri, SS params, AuthedDialogID auth) null {
    print("Booking: " + uri);
    S uri2 = appendSlash(uri);
    bool requestAuthed = auth != null;
  
    if (startsWith(uri2, "/booking-admin/")) {
      if (!requestAuthed) ret serveAuthForm(params.get('uri));
      ret serveBookingAdmin(uri, params);
    }
  }
  
  S serveBookingAdmin(S uri, SS params) {
    S nav = p(ahref(rawBotLink(dbBotID), botName + " Main admin") + " | " + ahref(baseLink + "/booking-admin", "Booking admin"));
    
    HCRUD_Concepts<BookingConfig> data = new HCRUD_Concepts<BookingConfig>(BookingConfig);
    data.fieldHelp = litmap(
      bookingOpen := "Whether booking is open at all",
      eventKeyword := "keyword to recognize events in Google Calendar, e.g. [SALES]",
      businessHoursMonday := "Regular hours available for appointments on Monday, e.g.: 9-12, 15-18",
      businessName := "What's your business called?",
      appointmentName := "Description of a typical appointment",
      appointmentDuration := "Duration of an appointment in minutes",
      appointmentGranularity := "Appointment grid size in minutes (e.g. 10)",
      appointmentMaxLead := "How many days in advance can an appointment be made",
      smsTemplate := "Template for appointment confirmation, e.g.: Hello [name], your [appointment] is scheduled for [time] on [date] at [business]",
      dateSuggestionsInGreeting := "How many date suggestions are shown in bot greeting (0 for no suggestions)",
    );
    data.onCreateOrUpdate.add(c -> initGoogleCalendar());

    HCRUD crud = new(rawLink("booking-admin"), data) {
      S frame(S title, S contents) {
        ret hhtml(hhead_title_htmldecode(title) + hbody(
          hsansserif() + hcss_responstable()
          + nav + h1(title)
          + p(join(". ", calendarStatus(), twilioStatus()))
          + h3("Business hours")
          + p(businessHourStatus())
          + contents));
      }
    };
    crud.singleton = true;
    crud.cmdsLeft = true;

    crud.tableClass = "responstable";
    ret crud.renderPage(params);
  }
  
  O twilio() { ret vmBus_query twilioHolder(); }
  
  bool twilioInited() { ret isTrue(call(twilio(), 'twilioInited)); }
  
  void initTwilio {
    if (twilioInited()) ret;
    if (empty(conf.twilioAccountSID) || empty(conf.twilioAuthToken) || empty(conf.twilioPhoneNr)) ret;
    
    pcall {
      print("initTwilio");
      O twilio = twilio();
      if (twilio == null) ret with print("No twilio holder in VM");
      call(twilio, 'twilioInit, conf.twilioAccountSID, conf.twilioAuthToken);
      print("initTwilio done");
    }
  }
  
  void initGoogleCalendar {
    googleCalendarService = null;
    BookingConfig c = conf;
    if (empty(c.googleCalendarAccessToken) || empty(c.googleCalendarRefreshToken)) ret;
    pcall {
      googleAccess.credentialFromTokens(c.googleCalendarAccessToken, c.googleCalendarRefreshToken);
      
      googleCalendarService = googleCalendarService(googleAccess, botName);
      print(+googleCalendarService);
    }
  }
  
  S calendarStatus() {
    ret googleCalendarService == null ? "Calendar NOT initialized (auth problem?)" : "Calendar access OK"; 
  }
  
  S twilioStatus() {
    ret twilioInited() ? "Twilio initialized" : "Twilio NOT initialized";
  }
  
  S businessHourStatus() {
    Map<Int, S> map = conf.businessHourStrings();
    new LS l;
    double totalHours = 0;
    for (int day, S s : map) {
      L<IntRange> ranges = parseBusinessHours_pcall(s);
      if (ranges == null)
        l.add(englishWeekday(day) + ": Error in business hour definition, please fix");
      else {
        double hours = totalIntRangesLength(ranges)/60.0;
        l.add(englishWeekday(day) + ": " + formatDouble(hours, 1) + (hours == 1 ? " hour" : " hours"));
        totalHours += hours;
      }
    }
    l.add("Total business hours per week: " + formatDouble(totalHours, 1));
    ret joinWithBR(l);
  }
  
  LongRange maxLead(long now) {
    ret longRange(now, dateStructureToTimestampRange(
      new DateStructures.TodayPlus(conf.appointmentMaxLead)).start);
  }

  S answer(S s, Conversation conv) {
    new DateInterpretationConfig config;
    config.now = nowInConv(conv);
    LongRange range = parseEnglishDateRange(s, config);
    if (range == null) null;
    
    // extend range to full day to show earlier & later buttons
    LongRange extendedRange = LongRange(
      beginningOfDay(range.start, localTimeZone()),
      endOfDay(range.end-1, localTimeZone()));
    
    long now = nowInConv(conv);
    print("now=" + now + ", testMode: " + conv.testMode);
    range = intersectLongRanges(range, maxLead(now));
    if (range == null) ret template("#badTimeRange");
    print("range: " + range);
    
    // get events
    L<LongRange> eventRanges = getEventRanges(extendedRange, conv.testMode);
    
    // allPossibleDates = all possible dates in extended range (at least full day)
    TreeSet<Long> allPossibleDates = possibleDatesInTimeRange_unfiltered(extendedRange, conv.testMode);
    removeEventsFromPossibleDates(allPossibleDates, eventRanges);
    
    // possibleDates = possible dates in narrow (user-specified) range
    TreeSet<Long> possibleDates = possibleDatesInTimeRange_unfiltered(range, conv.testMode);
    removeEventsFromPossibleDates(possibleDates, eventRanges);
    print("Possible dates: " + l(possibleDates));
    
    S formattedRange = formatDateRange(extendedRange, " and ");
    
    if (empty(possibleDates)) {
      // no free slots in narrow range. try extended range
      
      if (empty(allPossibleDates)) {
        // No slots there either
        
        if (conf.dateSuggestionsOnFail != 0) {
          // Search for any date
          LongRange range2 = maxLead(nowInConv(conv));
          TreeSet<Long> possibleDates2 = possibleDates(range2, conv.testMode);
          print("Possible dates: " + l(possibleDates2));
          if (nempty(possibleDates2)) {
            proposeForm(new ProposeAppointmentsForm(possibleDates2, conf.dateSuggestionsOnFail));
            ret template("#noTimeSlots", range := formattedRange);
          }
          
          // we are fully booked out
          ret template("#bookedOut");
        }
        
        // send negative message, no suggestions
        ret template("#noTimeSlots", range := formattedRange);
      }
      
      // Suggest a different time on same day
      
      long proposedDate = first(allPossibleDates);
      ProposeAppointmentForm form = new(allPossibleDates, proposedDate);
      form.setTemplate("#suggestOtherTime");
      proposeForm(form);
      ret ""; // form gives output
    }
      
    // At this point we have found possible time slots in narrow range
      
    long proposedDate = first(possibleDates);
    ProposeAppointmentForm form = new(allPossibleDates, proposedDate);
    form.setTemplate("#yesWeCanDoThatHowAboutDate");
    // Change msg if time range is an hour or shorter
    if (range.length() <= hoursToMS(1)) form.setTemplate("#confirmDate");
    proposeForm(form);
    
    ret ""; // form gives output
  }
  
  L<LongRange> getEventRanges(LongRange range, bool testMode) {
    long duration = minutesToMS(conf.appointmentDuration);
    int maxEvents = 10000;
    L<LongRange> eventRanges;
    if (testMode)
      eventRanges = testModeEventRanges();
    else {
      time "Get events from calendar" {
        L<Event> events = googleCalendar_eventsInDateRange(booking.googleCalendarService, localTimeZone(), range, maxEvents);
        events = filterEventsByKeyword(events);
        eventRanges = map(events, evt -> longRange(
          evt.getStart().getDateTime().getValue()-duration+1,
          evt.getEnd().getDateTime().getValue()));
      }
    }
    ret eventRanges;
  }
  
  TreeSet<Long> possibleDatesInTimeRange_unfiltered(LongRange range, bool testMode) {
    long granularity = granularity();
    L<LongRange> ranges = sliceTimeRangeIntoBusinessHours(range, testMode);
    // ranges have appointment duration cut off at end already, so they may be of zero length and be OK
    print("Sliced ranges: " + lambdaMap formatDateRange(ranges));
    ranges = map(r -> cutLongRangeToGranularity(r, granularity), ranges);
    // drop ranges too small for granularity
    ranges = filter(ranges, r -> r.length() >= 0);
    
    // now we have a workable set of time ranges, each one being good for at least one appointment
    print("Final ranges: " + lambdaMap formatDateRange(ranges) + ", granularity: " + granularity);
    
    // split into possible starting times
    new TreeSet<Long> possibleDates;
    for (LongRange r : ranges)
      for (long x = r.start; x <= r.end; x += granularity)
        possibleDates.add(x);
    print("Possible dates: " + l(possibleDates));
    ret possibleDates;
  }
  
  void removeEventsFromPossibleDates(TreeSet<Long> possibleDates, Cl<LongRange> eventRanges) {
    for (LongRange evt : eventRanges) {
      SortedSet<Long> subSet = possibleDates.subSet(evt.start, evt.end);
      if (nempty(subSet)) {
        //print("Removing dates due to event: " + map(d -> formatDateWithSeconds(d, timeZone()), subSet));
        subSet.clear();
      }
    }
  }

  TreeSet<Long> possibleDates(LongRange range, bool testMode) {
    L<LongRange> eventRanges = getEventRanges(range, testMode);
    TreeSet<Long> possibleDates = possibleDatesInTimeRange_unfiltered(range, testMode);
    removeEventsFromPossibleDates(possibleDates, eventRanges);
    ret possibleDates;
  }
  
  L<Event> filterEventsByKeyword(L<Event> l) {
    ret filter(l, e -> cic(e.getSummary(), conf.eventKeyword));
  }
  
  L<LongRange> sliceTimeRangeIntoBusinessHours(LongRange range, bool testMode) {
    long firstDay = beginningOfDay(range.start, timeZone());
    long lastDay = beginningOfDay(range.end, timeZone());
    lastDay = min(firstDay+daysToMS(365), lastDay); // just for safety, should not be necessary
    new L<LongRange> out;
    Map<Int, L<IntRange>> businessHours = businessHours(testMode);
    long duration = minutesToMS(conf.appointmentDuration);
    for (long day = firstDay; day <= lastDay; day += daysToMS(1)) {
      int weekday = dayOfWeek_nr(day, timeZone());
      L<IntRange> hours = businessHours.get(weekday);
      fOr (IntRange r : hours) {
        LongRange r2 = longRange(day+r.start*60*1000, day+r.end*60*1000-duration);
        r2 = intersectLongRanges(range, r2);
        if (r2 != null) // zero length is actually OK
          out.add(r2);
      }
    }
    ret out;
  }
  
  S dateAndTimeFormat() { ret dateFormat() + " " + timeFormat(); }
  S dateFormat() { ret "EEE M/dd/y"; }
  S timeFormat() { ret "hh:mm aa"; }
  
  TimeZone timeZone() { ret localTimeZone(); }
  
  S formatDate(long date) { ret main formatDate(date, dateAndTimeFormat(), timeZone()); }
  S formatDateRange(LongRange r, S connector default " to ") { ret r == null ? null : formatDate(r.start) + connector + formatDate(r.end); }
  
  long granularity() {
    ret minutesToMS(max(conf.appointmentGranularity, 5));
  }
  
  S acceptAppointment(Conversation conv, long date) {
    proposeForm(conv, new TakeUserInfoForm(date));
    ret "";
  }
  
  O serveTwilioWebhook(S uri, SS params) {
    S twilioSig = subBot_getHeader("X-Twilio-Signature");
    printAndProgramLog("twilioSig=" + twilioSig);
    print(+params);
    S url = subBot_completeRequestURL();
    printAndProgramLog(+url);
    programLog("webhook: " + params);
    
    initTwilio();
    bool validated = isTrue(call(twilio(), 'twilioValidateRequest, conf.twilioAuthToken, url, params, twilioSig));
    printAndProgramLog(+validated);
    if (!validated) ret "not validated";
    
    S accountSID = params.get("AccountSid");
    S body = params.get("Body");
    S from = params.get("From");
    
    S msgTo = conf.ownerPhoneNr;
    S msgText = from + " said: " + body;

    try {    
      virtual Message message = call(twilio(), 'twilioSend,
        conf.twilioPhoneNr, msgTo, msgText);
      printAndProgramLog("Message sent: " + message);
    } catch print e {
      programLog(stackTraceToString(e));
    }

    ret hfulltag Response("");
  }
  
  Map<Int, L<IntRange>> businessHours(bool testMode) {
    ret testMode ? testModeBusinessHours()
     : mapValues parseBusinessHours_pcall(conf.businessHourStrings());
  }
  
  bool anyBusinessHours() {
    ret !allEmpty(values(businessHours(false)));
  }
  
  void checkIfAnyTimeSlots {
    int n = l(possibleDates(maxLead(now()), false));
    print("Found " + n2(n, "possible date") + " in near future");
    anyTimeSlots = n != 0;
  }
  
  bool anyBookingPossible() {
    ret conf.bookingOpen && anyBusinessHours() && anyTimeSlots;
  }
  
  // pick n earliest dates, but try to show one am + one pm date
  L<Long> pickDates(int n, Cl<Long> possibleDates) {
    new LinkedHashSet<Long> l;
    if (nempty(possibleDates)) {
      long first = first(possibleDates);
      l.add(first);
      
      // find date from other part of day
      if (n >= 2) {
        bool firstIsPM = hours(first, localTimeZone()) > 12;
        addIfNotNull(l, firstThat(dropFirst(possibleDates),
          d -> (hours(d, localTimeZone()) > 12) != firstIsPM));
      }
      
      // fill up from start
      for (long date : possibleDates) {
        if (l(l) >= n) break;
        l.add(date);
      }
    }
    ret asList(l);
  }
} // end of Booking

// show one suggestion + earlier/later buttons
sclass ProposeAppointmentForm > FormInFlight {
  TreeSet<Long> possibleDates;
  long date;
  S template = "#howAboutDate";
  
  *() {}
  *(TreeSet<Long> *possibleDates, long *date) {
    makeSteps();
  }
  
  void setTemplate(S template) {
    this.template = template;
    makeSteps();
  }
  
  void resetTemplate() {
    template = new ProposeAppointmentForm().template;
  }
  
  void makeSteps {
    steps = ll(nu FormStep(
      key := "start",
      displayText := template(template, date := booking.formatDate(date)),
      buttons := ll_nonNulls(template("#illTakeIt"),
        possibleDates.lower(date) == null ? null : "Earlier please",
        possibleDates.higher(date) == null ? null : "Later please"),
      //allowFreeText := true
    ));
    change();
  }
  
  S handleInput(S s) null {
    new Matches m;
    if "...earlier..." {
      Long date2 = possibleDates.lower(date);
      if (date2 == null) ret template("#noEarlierDate");
      date = date2; resetTemplate(); makeSteps();
      ret ""; // serve first step again
    }
    
    if "...later..." {
      Long date2 = possibleDates.higher(date);
      if (date2 == null) ret template("#noLaterDate");
      date = date2; resetTemplate(); makeSteps();
      ret ""; // serve first step again
    }
    
    if (find3(template("#illTakeIt"), s))
      ret booking.acceptAppointment(cancelMe(), date);
  }

  bool allowGeneralOverride() { true; }
}

// this one shows multiple suggestions
sclass ProposeAppointmentsForm > FormInFlight {
  int maxDatesToShow = 2;
  L<Long> possibleDates;
  LS dateTexts;
  
  *() {}
  *(Cl<Long> possibleDates, int *maxDatesToShow) {
    this.possibleDates = booking.pickDates(maxDatesToShow, possibleDates);
    dateTexts = map(d -> booking.formatDate(d), this.possibleDates);
    makeSteps();
  }
  
  void makeSteps {
    steps = ll(nu FormStep(
      key := "select",
      buttons := dateTexts,
      //allowFreeText := true
    ));
    change();
  }
  
  S handleInput(S s) null {
    new Matches m;
    for i over dateTexts:
      if (cic(s, dateTexts.get(i))) {
        long date = possibleDates.get(i);
        ret booking.acceptAppointment(cancelMe(), date);
      }
  }
  
  bool allowGeneralOverride() { true; }
}

sclass TakeUserInfoForm > FormInFlight {
  long date;
  
  *() {}
  *(long *date) {
    steps.add(nu FormStep(
      key := "name",
      displayText := template("#bookingYouForPlusName", date := booking.formatDate(date)),
      placeholder := template("#yourName")
    ));
    
    steps.add(nu USPhoneNrStep(
      key := "phone",
      displayText := template("#pleaseEnterAPhoneNr"),
      placeholder := template("#yourPhoneNr")
    ));
  }
  
  S complete() ctex {
    BookingConfig conf = booking.conf;
    if (!conversation.testMode) {
      S name = shorten(getValue("name"), 50);
      S title = conf.eventKeyword + " " + conf.appointmentName + " with " + name;
      Event event = new Event()
        .setSummary(title)
        .setLocation(conf.businessName)
        .setDescription("Booked through chat bot on " + ymdWithSlashes(nowInConv(conversation), localTimeZone()) + ". Phone contact: " + getValue("phone"));
    
      DateTime startDateTime = new DateTime(new java.util.Date(date), localTimeZone());
      //S dateText = formatDateForGoogleAPI(date, localTimeZone());
      //printVars_str(+dateText, timeZone := localTimeZone(), +timeZoneForGoogleAPI);
      //DateTime startDateTime = new DateTime(dateText);
      EventDateTime start = new EventDateTime()
        .setDateTime(startDateTime)
        .setTimeZone(timeZoneForGoogleAPI);
      event.setStart(start);
      
      long endDate = date+minutesToMS(conf.appointmentDuration);
      DateTime endDateTime = new DateTime(new java.util.Date(endDate), localTimeZone());
      //DateTime endDateTime = new DateTime(formatDateForGoogleAPI(endDate, localTimeZone()));
      EventDateTime end = new EventDateTime()
        .setDateTime(endDateTime)
        .setTimeZone(timeZoneForGoogleAPI);
      event.setEnd(end);
      
      /*EventAttendee[] attendees = new EventAttendee[] {
          new EventAttendee().setEmail(...)
      };
      event.setAttendees(Arrays.asList(attendees));*/
      
      /*EventReminder[] reminderOverrides = new EventReminder[] {
          new EventReminder().setMethod("email").setMinutes(24 * 60),
          new EventReminder().setMethod("popup").setMinutes(10),
      };
      Event.Reminders reminders = new Event.Reminders()
          .setUseDefault(false)
          .setOverrides(Arrays.asList(reminderOverrides));
      event.setReminders(reminders);*/
  
      event = booking.googleCalendarService.events().insert("primary", event).execute();
      print("Event created: " + event.getHtmlLink());
      
      if (!isDummyPhoneNr(getValue("phone"))) thread "Twilio" {
        booking.initTwilio();
        if (!booking.twilioInited()) ret with print("Twilio not available");
        print("Sending Twilio msg");
        S text = conf.smsTemplate;
        text = replaceIC(text, "[date]", ymd(date, localTimeZone()));
        text = replaceIC(text, "[time]", timeInZone_24(date, localTimeZone()));
        text = replaceIC(text, "[business]", conf.businessName);
        text = replaceIC(text, "[appointment]", conf.appointmentName);
        text = replaceIC(text, "[name]", name);
        S cleanedPhoneNr = trim(getValue("phone"));
        if (!startsWithOneOf(cleanedPhoneNr, "+1", "1-", "1 ", "1("))
          cleanedPhoneNr = "1" + cleanedPhoneNr;
        cleanedPhoneNr = "+" + digitsOnly(cleanedPhoneNr);
        print("Cleaned phone number: " + cleanedPhoneNr);
        print("SMS text: " + text);
        virtual Message message = call(booking.twilio(), 'twilioSend,
          conf.twilioPhoneNr, cleanedPhoneNr, text);
        print("Twilio msg sent: " + message);
      }
    }

    ret template("#youAreBooked", name := getValue("name"), date := booking.formatDate(date));
  }
}

sclass USPhoneNrStep > FormStep {
  S verifyData(S s) null {
    s = digitsOnly(s);
    if (contains(usAreaCodes(), takeFirst(s, 3))
      || startsWith(s, "1") && contains(usAreaCodes(), substring(s, 1, 4))) {
      // area code ok
    } else
      ret template("#invalidAreaCode");
      
    if (countDigitsInString(s) < 7)
      ret template("#tooFewDigits");
  }
}

static long nowInConv(Conversation conv) {
  ret conv.testMode ? testModeDate() : now();
}

download  show line numbers  debug dex   

Travelled to 5 computer(s): bhatertpkbcr, mqqgnosmbjvj, pzhvpgtvlbxg, tvejysmllsmz, xrpafgyirdlv

No comments. add comment

Snippet ID: #1028621
Snippet name: Booking [Include for BookBetterNow]
Eternal ID of this version: #1028621/175
Text MD5: 9a1022e95cf4d53dd3b318fa9c6ff1f2
Author: stefan
Category: javax
Type: JavaX fragment (include)
Public (visible to everyone): Yes
Archived (hidden from active list): No
Created/modified: 2020-07-31 13:03:22
Source code size: 22470 bytes / 620 lines
Pitched / IR pitched: No / No
Views / Downloads: 213 / 690
Version history: 174 change(s)
Referenced in: [show references]

Formerly at http://tinybrain.de/1028621 & http://1028621.tinybrain.de