Skip to content
This repository has been archived by the owner on Apr 29, 2024. It is now read-only.

Commit

Permalink
update nora with slack access
Browse files Browse the repository at this point in the history
  • Loading branch information
Chris Balthazard committed Apr 26, 2024
1 parent c59f985 commit 86bc453
Show file tree
Hide file tree
Showing 2 changed files with 150 additions and 107 deletions.
250 changes: 143 additions & 107 deletions lib/nora/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,42 +14,45 @@
require "pony"
require "memo_wise"

require "thin"
require "colorize"
require "sinatra/base"

require "optparse"
require "open-uri"
require "json"
require "csv"

module Nora
class Core
prepend MemoWise

PAIRINGS_FILE = "past_pairings.txt"
PAIRINGS_FILE_SEPARATOR = " "

OOB_URI = "urn:ietf:wg:oauth:2.0:oob"
PORT = 3017
REDIRECT_URI = "http://localhost:#{PORT}/"

# 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

SLACK_CLIENT_SECRETS_PATH = "slack_client_secret.json"

CALENDAR_BATCH_SIZE = 250 # The maximum Google Calendar allows
FREE_BUSY_QUERY_BATCH_SIZE = 5

SLACK_API_CONVERSATIONS_MEMBERS_URL = "https://slack.com/api/conversations.members"
SLACK_API_USERS_INFO_URL = "https://slack.com/api/users.info"
SLACK_SECRETS_PATH = "slack_client_secret.json"

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
def initialize(parsed_options)
@weeks_ahead = parsed_options[:weeks_ahead]
@test = parsed_options[:test]

FileUtils.touch(PAIRINGS_FILE) # Make sure pairings file exists.

Expand All @@ -66,14 +69,12 @@ def initialize(weeks_ahead:, test:)

def run!
load_history! # Populate @history for load_calendars!
load_calendars! # Outside the loop to reduce calls to Google API.
loaded_calendars = load_calendars! # Outside the loop to reduce calls to Google API.
create_groups!

send_emails(
template_emails_for(
schedule_meetings!
)
)
schedule_meetings!

unload_calendars!(loaded_calendars)

puts "Done."
end
Expand All @@ -88,14 +89,28 @@ def load_calendars!
puts "Loading calendars..."
@emails = CONFIGURATION["people"].map { |p| p["email"] }

@calendars_to_load = (Set.new(@emails) - @previously_loaded_emails)

# Load all calendars that aren't in our history.
(Set.new(@emails) - @previously_loaded_emails).each do |email|
@calendars_to_load.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

@calendars_to_load
end

# Removes all calendars so the person running this script
# isn't left subscribed to everyone's calendar.
def unload_calendars!(calendars)
calendars.each do |email|
puts "Unloading calendar: #{email}"
@service.delete_calendar_list(email)
sleep 1 # Avoid exceeding Google's rate limit.
end
end

def load_history!
Expand Down Expand Up @@ -192,65 +207,6 @@ def schedule_meetings!
meetings
end

def template_emails_for(appointments)
appointments.map do |appt|
names = appt[:who].map { |w| w[:name] }.join(" and ")

# rubocop:disable Layout/MultilineMethodCallIndentation
message =
case appt[:on]
when :no_time
num_weeks = @weeks_ahead + 1
CONFIGURATION["email"]["templates"]["no_time"].
gsub("NAMES", names).
gsub("ICEBREAKER", icebreaker).
gsub("WEEKS_AHEAD", "#{num_weeks} #{'week'.pluralize(num_weeks)}")
when :no_group
num_weeks = @weeks_ahead + 1
CONFIGURATION["email"]["templates"]["no_group"].
gsub("WEEKS_AHEAD", "#{num_weeks} #{'week'.pluralize(num_weeks)}")
else
datetime = appt[:on].in_time_zone("Eastern Time (US & Canada)")
day = datetime.strftime("%B %-d")
military_time = datetime.strftime("%H%M")

CONFIGURATION["email"]["templates"]["default"].
gsub("NAMES", names).
gsub("DAY", day).
gsub("MILITARY_TIME", military_time).
gsub("ICEBREAKER", icebreaker)
end
# rubocop:enable Layout/MultilineMethodCallIndentation

{
to: appt[:who],
subject: "NORA: Mission Briefing",
body: message
}
end
end

def send_emails(email_messages)
return if @test

puts "Sending emails..."

email_messages.each do |msg|
msg[:to].each do |to|
Pony.mail(
to: to[:email],
from: CONFIGURATION["email"]["from_address"],
subject: msg[:subject],
body: msg[:body],
via: :smtp,
charset: "UTF-8"
)
rescue StandardError => e
puts e
end
end
end

def add_meeting(time:, attendee_ids:)
return if @test

Expand All @@ -259,20 +215,22 @@ def add_meeting(time:, attendee_ids:)
Google::Apis::CalendarV3::Event.new(
summary:
"NORA: #{attendee_ids.map { |id| email_name(id) }.join('/')}",
description: "Icebreaker question: #{icebreaker}",
description: "#{icebreaker}",
start: Google::Apis::CalendarV3::EventDateTime.new(
date_time: time.to_datetime
),
end: Google::Apis::CalendarV3::EventDateTime.new(
date_time: (
time + CONFIGURATION["calendar"]["duration_in_minutes"]
time + CONFIGURATION["calendar"]["duration_in_minutes"]*60
).to_datetime
),
attendees: attendee_ids.map do |id|
Google::Apis::CalendarV3::EventAttendee.new(email: id)
end,
guests_can_modify: true
)
guests_can_modify: true,
organizer: Google::Apis::CalendarV3::Event::Organizer.new(display_name: "NORAbot")
),
# send_updates: "all" # TODO
)
sleep 1 # Avoid exceeding Google's rate limit.
end
Expand Down Expand Up @@ -301,7 +259,7 @@ def availability_schedule
free_busy.busy.each do |busy_period|
all_availabilities.each do |time, ids|
unless (busy_period.start.to_time <= (
time + CONFIGURATION["calendar"]["duration_in_minutes"] -
time + CONFIGURATION["calendar"]["duration_in_minutes"].minutes -
1.minute
)) && (busy_period.end.to_time >= (time + 1.minute))
next
Expand All @@ -313,6 +271,8 @@ def availability_schedule
end
end

# TODO: add OOO company days (like calpeat)

# Now remove hash values that have <2 IDs in their set.
all_availabilities.select { |_, ids| ids.size > 1 }
end
Expand All @@ -328,12 +288,40 @@ def email_name(email)
end

def email_name_map
CONFIGURATION["people"].each_with_object({}) do |data, h|
h[data["email"]] = data["name"]
end
pull_emails_from_slack!
end
memo_wise :email_name_map

def pull_emails_from_slack!
member_ids = pull_nora_channel_members_from_slack!

member_uri = URI(SLACK_API_USERS_INFO_URL)
member_ids.each_with_object({}) do |member_id, h|
member_body = "token=#{from_file(SLACK_SECRETS_PATH, "app_token")}&user=#{member_id}"
response = Net::HTTP.post(member_uri, member_body)
data = JSON.parse(response.body)
next if data["user"]["profile"]["email"].nil?

h[data["user"]["profile"]["email"]] = data["user"]["profile"]["real_name"]
end.compact
end

def pull_nora_channel_members_from_slack!
conversation_members_uri = URI(SLACK_API_CONVERSATIONS_MEMBERS_URL)
conversation_members_body = "token=#{from_file(SLACK_SECRETS_PATH, "app_token")}&channel=#{from_file(SLACK_SECRETS_PATH, "nora_channel_id")}"
response = Net::HTTP.post(conversation_members_uri, conversation_members_body)
JSON.parse(response.body)["members"]
end

def from_file(file, key)
raise "File can not be nil." if file.nil?
File.open file.to_s do |f|
json = f.read
config = MultiJson.load json
config[key]
end
end

def calendars
output = []
lists = @service.list_calendar_lists(max_results: CALENDAR_BATCH_SIZE)
Expand Down Expand Up @@ -381,7 +369,7 @@ def end_of_week
memo_wise :end_of_week

# Ensure valid credentials, either by restoring from the saved credentials
# files or intitiating an OAuth2 authorization. If authorization is
# files or initiating an OAuth2 authorization. If authorization is
# required, the user will be instructed appropriately.
# @return [Google::Auth::UserRefreshCredentials] OAuth2 credentials
def authorize
Expand All @@ -391,24 +379,72 @@ def authorize
token_store = Google::Auth::Stores::FileTokenStore.
new(file: CREDENTIALS_PATH)
authorizer = Google::Auth::UserAuthorizer.
new(client_id, SCOPE, token_store)
credentials = authorizer.
get_credentials(CONFIGURATION["calendar"]["user_id"])
new(client_id, SCOPE, token_store, REDIRECT_URI)
user_id = CONFIGURATION["calendar"]["user_id"]
credentials = authorizer.get_credentials(user_id)
if credentials.nil?
url = authorizer.get_authorization_url(base_url: OOB_URI)
puts "Open the following URL in the browser and enter the "\
"resulting code after authorization"
puts url
code = gets
credentials = authorizer.get_and_store_credentials_from_code(
user_id: CONFIGURATION["calendar"]["user_id"],
code: code,
base_url: OOB_URI
)
server_thread = run_local_server(authorizer, PORT, user_id)
url = authorizer.get_authorization_url
$stderr.puts ""
$stderr.puts "-----------------------------------------------"
$stderr.puts "Requesting authorization for '#{user_id.yellow}'"
$stderr.puts "Open the following URL in your browser and authorize the application."
$stderr.puts
$stderr.puts url.yellow.bold
$stderr.puts
$stderr.puts "⚠️ If you are authorizing on a different machine, you will have to port-forward"
$stderr.puts "so your browser can reach #{REDIRECT_URI.yellow}"
$stderr.puts
$stderr.puts "⚠️ If you get a " + "This site can't be reached".red + " error in the browser,"
$stderr.puts "just copy the code which is in the code= part of the failing address on the next line."
$stderr.puts "E.g., you need only the " + "green".bold.green + " part of the address which looks like"
$stderr.puts "#{REDIRECT_URI}?code=".yellow + "4/QMoyZIyzt8uXO6j...j8ajEEjfd".bold.green + "&scope=email%20profile...".yellow
$stderr.puts "-----------------------------------------------"
`open "#{url}"`
code = STDIN.gets
server_thread[:server].stop!
server_thread.join
credentials = authorizer.get_credentials(user_id)
if credentials.nil?
credentials = authorizer.get_and_store_credentials_from_code(user_id: user_id, code: code, base_url: REDIRECT_URI)
end
end
credentials
end

def run_local_server(authorizer, port, user_id)
Thin::Logging.silent = true
Thread.new {
Thread.current[:server] = Sinatra.new do
enable :quiet
disable :logging
set :port, port
set :server, %w[ thin ]
get "/" do
request = Rack::Request.new env
state = {
code: request["code"],
error: request["error"],
scope: request["scope"]
}
raise Signet::AuthorizationError, ("Authorization error: %s" % [ state[:error] ] ) if state[:error]
raise Signet::AuthorizationError, "Authorization code missing from the request" if state[:code].nil?
credentials = authorizer.get_and_store_credentials_from_code(
user_id: user_id,
code: state[:code],
scope: state[:scope],
)
[
200,
{ "Content-Type" => "text/plain" },
"All set! You can close this window and press ENTER in the application to proceed.",
]
end
end
Thread.current[:server].run!
}
end

def group_size
CONFIGURATION["group_size"]
end
Expand Down
7 changes: 7 additions & 0 deletions nora.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,11 @@ Gem::Specification.new do |spec|
spec.add_dependency "google-api-client", "~> 0.10"
spec.add_dependency "memo_wise", ">= 0.4", "< 2.0"
spec.add_dependency "pony", "~> 1.12"
spec.add_dependency "thin", "~> 1.7"
spec.add_dependency "sinatra", "~> 3.2"
spec.add_dependency "colorize", "~> 1.1"
spec.add_dependency "open-uri"
spec.add_dependency "csv"
spec.add_dependency "json"
spec.add_dependency "optparse"
end

0 comments on commit 86bc453

Please sign in to comment.