# frozen_string_literal: true require "google/apis/calendar_v3" require "googleauth" require "googleauth/stores/file_token_store" require "fileutils" require "active_support" require "active_support/core_ext/time/zones" require "active_support/core_ext/numeric/time" require "active_support/duration" require "chronic" require "pony" module Nora class Core extend Memoist PAIRINGS_FILE = "past_pairings.txt" PAIRINGS_FILE_SEPARATOR = " " OOB_URI = "urn:ietf:wg:oauth:2.0:oob" CLIENT_SECRETS_PATH = "nora_client_secret.json" CREDENTIALS_PATH = "calendar-ruby-quickstart.yaml" SCOPE = Google::Apis::CalendarV3::AUTH_CALENDAR CALENDAR_BATCH_SIZE = 250 # The maximum Google Calendar allows FREE_BUSY_QUERY_BATCH_SIZE = 5 CONFIGURATION = JSON.parse(File.read("nora_configuration.json")) Pony.options = { via: :smtp, via_options: { address: "smtp.sendgrid.net", port: "587", domain: CONFIGURATION["email"]["domain"], authentication: :plain, enable_starttls_auto: true, # These Sendgrid credentials come from the Heroku addon. user_name: CONFIGURATION["email"]["sendgrid_configuration"]["user_name"], password: CONFIGURATION["email"]["sendgrid_configuration"]["password"] } } def initialize(weeks_ahead:, test:) @weeks_ahead = weeks_ahead @test = test FileUtils.touch(PAIRINGS_FILE) # Make sure pairings file exists. # Set global Chronic parsing time zone. Time.zone = "UTC" Chronic.time_class = Time.zone # Initialize the API @service = Google::Apis::CalendarV3::CalendarService.new @service.client_options.application_name = CONFIGURATION["calendar"]["application_name"] @service.authorization = authorize end def run! load_history! # Populate @history for load_calendars! load_calendars! # Outside the loop to reduce calls to Google API. create_groups! send_emails( template_emails_for( schedule_meetings! ) ) puts "Done." end private # Adds all calendars to NORA's calendar, so that # `@service.list_calendar_lists` will have every # calendar in the configuration file without us # having to manually add them in the UI. def load_calendars! puts "Loading calendars..." @emails = CONFIGURATION["people"].map { |p| p["email"] } # Load all calendars that aren't in our history. (Set.new(@emails) - @previously_loaded_emails).each do |email| puts "Loading calendar: #{email}" @service.insert_calendar_list( Google::Apis::CalendarV3::CalendarListEntry.new(id: email) ) sleep 1 # Avoid exceeding Google's rate limit. end end def load_history! puts "Loading history..." @past_pairing_counts = Hash.new { |h, k| h[k] = 0 } @previously_loaded_emails = Set.new File.open(PAIRINGS_FILE).each do |line| line.split(PAIRINGS_FILE_SEPARATOR).each do |email| @previously_loaded_emails << email end.permutation.each do |emails| @past_pairing_counts[group_key(emails: emails)] += 1 end end end def group_key(emails:) emails.sort.join(",") end def create_groups! puts "Creating groups..." unmatched_emails = Set.new(@emails) @groups = [] while unmatched_emails.size >= group_size @emails.shuffle.combination(group_size).each do |emails| next unless emails.all? { |email| unmatched_emails.include?(email) } key = group_key(emails: emails) if @past_pairing_counts[key].zero? @groups << emails unmatched_emails.subtract(emails) else @past_pairing_counts[key] -= 1 end end end end # @return [Array] list of meetings, of the format: # { on: