Skip to content

Commit

Permalink
Merge pull request #42397 from code-dot-org/katie/dotd-slack-integration
Browse files Browse the repository at this point in the history
Update DOTD Slack Integration
  • Loading branch information
KatieShipley committed Oct 12, 2021
2 parents 594959b + 179d87d commit eac63bc
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 120 deletions.
8 changes: 3 additions & 5 deletions aws/ci_build
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,9 @@ def main
ChatClient.message 'server operations', commit_url, color: 'gray', message_format: 'text'
DevelopersTopic.set_dtp 'yes'
InfraProductionTopic.set_dtp_commit GitHub.sha('production')
ChatClient.set_reminder(
'developers',
"@#{DevelopersTopic.dotd}",
"to check Zendesk in 2 hours"
)

# Schedule a reminder for the dotd to check Zendesk in 2 hours
Slack.remind(Slack.user_id(DevelopersTopic.dotd), Time.now.to_i + 7200, 'Reminder: check <https://codeorg.zendesk.com/agent/filters/44863373|Zendesk>')

# Check hoc_mode and hoc_launch only on successful DTPs, so that we get about 1 reminder per
# day to bring staging, test and production to the same hoc_mode and hoc_launch after a
Expand Down
15 changes: 3 additions & 12 deletions bin/dotd
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ def check_for_cdo_keys

puts <<-EOS.unindent
This script requires CDO.github_access_token, CDO.slack_token, and CDO.honeybadger_api_token and CDO.devinternal_db_writer.
This script requires CDO.github_access_token, CDO.slack_bot_token, and CDO.honeybadger_api_token and CDO.devinternal_db_writer.
Create your API tokens from these pages:
https://github.com/settings/tokens ('public_repo' permission)
https://api.slack.com/custom-integrations/legacy-tokens
https://app.honeybadger.io/users/edit#authentication
The slack_bot_token can be found in the Shared-Engineering folder in LastPass, under DOTD Slack Bot Token.
Please add them to your locals.yml and rerun the script.
CDO.devinternal_db_writer should be pulled automatically from AWS Secrets Manager.
Expand Down Expand Up @@ -476,16 +477,6 @@ end
def main
check_for_cdo_keys

# auto-join Slack rooms where the DOTD might be @mentioned
# or need to affect the room topic.
Slack.join_room('developers')
Slack.join_room('deploy-status')
Slack.join_room('infra-staging')
Slack.join_room('infra-test')
Slack.join_room('infra-production')
Slack.join_room('infra-honeybadger')
Slack.join_room('levelbuilder')

dotd_name = ENV['CDODEV_DOTD_NAME'] || ENV['USER']
@logger.info("#{Time.new.strftime('%A, %B %d %Y')}: #{dotd_name} is DOTD")

Expand Down
1 change: 1 addition & 0 deletions config.yml.erb
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ slack_set_last_dtt_green_token:
slack_start_build_token:
slack_endpoint: !Secret
slack_token: !Secret
slack_bot_token: !Secret
slack_log_room: <%=env%>
hip_chat_logging: false
# Logging endpoint used by broken link checker.
Expand Down
8 changes: 0 additions & 8 deletions lib/cdo/chat_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,6 @@ def self.message(room, message, options={})
)
end

# @param room [String] Name of the Slack channel to post /remind to.
# @param recipient [String] Slack user to remind, include @ in the argument
# @param reminder [String] Message for the /remind commmand
def self.set_reminder(room, recipient, reminder)
message = recipient + " " + reminder
Slack.command(room, "remind", message)
end

def self.snippet(message)
Slack.snippet(CDO.slack_log_room, message)
end
Expand Down
199 changes: 118 additions & 81 deletions lib/cdo/slack.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
require 'uri'
require 'net/http'
require 'open-uri'
require 'retryable'
require 'json'
require 'cdo/honeybadger'

class Slack
COLOR_MAP = {
Expand All @@ -17,22 +19,42 @@ class Slack
'production' => 'infra-production'
}.freeze

SLACK_TOKEN = CDO.slack_token.freeze
# Common channel name to ID mappings
CHANNEL_IDS = {
'developers' => 'C0T0PNTM3',
'deploy-status' => 'C7GS8NE8L',
'infra-staging' => 'C03CK8E51',
'infra-test' => 'C03CM903Y',
'infra-production' => 'C03CK8FGX',
'infra-honeybadger' => 'C55JZ1BPZ',
'levelbuilder' => 'C0T10H2HY',
'server-operations' => 'C0CCSS3PX'
}.freeze

SLACK_TOKEN = CDO.methods.include?(:slack_token) ? CDO.slack_token.freeze : nil
SLACK_BOT_TOKEN = CDO.methods.include?(:slack_bot_token) ? CDO.slack_bot_token.freeze : nil

# Returns the user (mention) name of the user.
# WARNING: Does not include the mention character '@'.
# @param email [String] The email of the Slack user.
# @raise [ArgumentError] If the email does not correspond to a Slack user.
# @return [nil | String] The user (mention) name for the Slack user.
def self.user_name(email)
users_list = open("https://slack.com/api/users.list?token=#{SLACK_TOKEN}").
read
members = JSON.parse(users_list)['members']
members = post_to_slack("https://slack.com/api/users.list")['members']
raise "Failed to query users.list" unless members
user = members.find {|member| email == member['profile']['email']}
raise "Slack email #{email} not found" unless user
user['name']
end

def self.user_id(name)
members = post_to_slack("https://slack.com/api/users.list")['members']
raise "Failed to query users.list" unless members
user = members.find {|member| name == member['name']}
raise "Slack user #{name} not found" unless user
user['id']
end

# @param channel_name [String] The channel to fetch the topic.
# @return [String | nil] The existing topic, nil if not found.
def self.get_topic(channel_name, use_channel_map = false)
Expand All @@ -43,25 +65,9 @@ def self.get_topic(channel_name, use_channel_map = false)
channel_id = get_channel_id(channel_name)
return nil unless channel_id

response = Retryable.retryable(on: [Errno::ETIMEDOUT, OpenURI::HTTPError], tries: 2) do
open(
'https://slack.com/api/conversations.info'\
"?token=#{SLACK_TOKEN}"\
"&channel=#{channel_id}"\
)
end

begin
parsed_response = JSON.parse(response.read)
rescue JSON::ParserError
return nil
end

unless parsed_response['ok']
return nil
end

replace_user_links(parsed_response['channel']['topic']['value'])
response = post_to_slack("https://slack.com/api/conversations.info?channel=#{channel_id}")
return nil unless response
replace_user_links(response['channel']['topic']['value'])
end

# @param channel_name [String] The channel to update the topic.
Expand All @@ -77,30 +83,26 @@ def self.update_topic(channel_name, new_topic, use_channel_map = false)
channel_id = get_channel_id(channel_name)
return false unless channel_id

response = open('https://slack.com/api/conversations.setTopic'\
"?token=#{SLACK_TOKEN}"\
"&channel=#{channel_id}"\
"&topic=#{new_topic}"
)
result = JSON.parse(response.read)
raise "Failed to update_topic, with error: #{result['error']}" if result['error']
result['ok']
url = "https://slack.com/api/conversations.setTopic"
payload = {"channel" => channel_id, "topic" => new_topic}
result = post_to_slack(url, payload)
return !!result
end

def self.replace_user_links(message)
message.gsub(/<@(.*?)>/) {'@' + get_display_name($1)}
end

# @param user_id [String] The user whose name you are looking for.
# @return [String] Slack 'display_name' if one is set, otherwise Slack 'name'.
# Returns provided user_id if not found.
def self.get_display_name(user_id)
response = open(
'https://slack.com/api/users.info'\
"?token=#{SLACK_TOKEN}"\
"&user=#{user_id}"\
).read
response = post_to_slack("https://slack.com/api/users.info?user=#{user_id}")

return user_id unless response
parsed_response = JSON.parse(response)
return user_id unless parsed_response['ok']
parsed_response['user']['profile']['display_name']
profile = response['user']['profile']
return profile['display_name'] unless profile['display_name'] == ""
response['user']['name']
end

# For more information about the Slack API, see
Expand All @@ -113,6 +115,7 @@ def self.get_display_name(user_id)
# color (optional): The color the post should be.
# @return [Boolean] Whether the text was posted to Slack successfully.
# WARNING: This function mutates params.
# NOTE: This function utilizes an incoming webhook, not the Slack token
def self.message(text, params={})
return false unless CDO.slack_endpoint
params[:channel] = "\##{Slack::CHANNEL_MAP[params[:channel]] || params[:channel]}"
Expand Down Expand Up @@ -148,64 +151,46 @@ def self.message(text, params={})
end
end

# For more information see
# https://github.com/ErikKalkoken/slackApiDoc/blob/master/chat.command.md. NOTE This API is 'undocumented' and not part of the official Slack APIs.
# @param channel_name [String] Name of the Slack channel to post the command to.
# @param command [String] Command to execute, excluding the /.
# @param message [String] Optional text passed to the command.
# @return [Boolean] Whether the command was posted to Slack successfully.
def self.command(channel_name, command, message="")
channel_id = get_channel_id(channel_name)
response = open(
"https://slack.com/api/chat.command?channel=#{channel_id}"\
"&command=/#{command}"\
"&text=#{message}"\
"&token=#{SLACK_TOKEN}"
)

result = JSON.parse(response.read)
raise "Failed to post command with: #{result['error']}" if result['error']
result['ok']
# Bot tokens are unable to post to reminders.add or chat.command, so we will mimic the functionality of a slack reminder
# by scheduling a DM to the user at a specific time.
# @param recipient_id [String] Slack ID of user to message.
# @param time [String] Unix timestamp of the time the message should be sent.
# @param message [String] Text to be sent in the scheduled message.
def self.remind(recipient_id, time, message)
result = post_to_slack("https://slack.com/api/chat.scheduleMessage", {"channel" => recipient_id, "post_at" => time, "text" => message})
return !!result
end

# @param room [String] Channel name or id to post the snippet.
# @param text [String] Snippet text.
def self.snippet(room, text)
# omit leading '#' when passing channel names to this API
channel = CHANNEL_MAP[room] || room
open('https://slack.com/api/files.upload'\
"?token=#{SLACK_TOKEN}"\
"&content=#{URI.escape(text)}"\
"&channels=#{channel}"
)
result = post_to_slack("https://slack.com/api/files.upload?channels=#{channel}&content=#{URI.escape(text)}")
return !!result
end

# @param name [String] Name of the Slack channel to join.
def self.join_room(name)
response = open(
'https://slack.com/api/conversations.join'\
"?token=#{SLACK_TOKEN}"\
"&channel=#{get_channel_id(name)}"
)

result = JSON.parse(response.read)
raise "Failed to join_room, with error: #{result['error']}" if result['error']
result['ok']
channel = get_channel_id(name)
return false unless channel
result = post_to_slack("https://slack.com/api/conversations.join", {"channel" => channel})
return !!result
end

# Returns the channel ID for the channel with the requested channel_name.
# @param channel_name [String] The name of the Slack channel.
# @return [nil | String] The Slack channel ID for the channel, nil if not
# found.
private_class_method def self.get_channel_id(channel_name)
return CHANNEL_IDS[channel_name] if CHANNEL_IDS[channel_name]

raise "CDO.slack_token undefined" if SLACK_TOKEN.nil?
# Documentation at https://api.slack.com/methods/channels.list.
slack_api_url = "https://slack.com/api/conversations.list"\
"?token=#{SLACK_TOKEN}&limit=1000&types=public_channel&exclude_archived=true"
channels = open(slack_api_url).read
begin
parsed_channels = JSON.parse(channels)
rescue JSON::ParserError
return nil
end
return nil unless parsed_channels['channels']
url = "https://slack.com/api/conversations.list?limit=1000&types=public_channel&exclude_archived=true"
parsed_channels = post_to_slack(url)
return nil unless parsed_channels && parsed_channels['channels']

parsed_channels['channels'].each do |parsed_channel|
return parsed_channel['id'] if parsed_channel['name'] == channel_name
end
Expand All @@ -226,4 +211,56 @@ def self.join_room(name)
gsub(/<\/a>/, '>').
gsub(/<br\/?>/, "\n")
end

private_class_method def self.post_to_slack(url, payload = nil)
if SLACK_BOT_TOKEN && SLACK_BOT_TOKEN != ''
token = SLACK_BOT_TOKEN
else
# TODO: Remove after deprecating legacy SLACK_TOKEN
opts = {
error_class: "Slack integration [warn]",
error_message: "Using legacy token",
context: {url: url, payload: payload}
}
Honeybadger.notify_cronjob_error opts

token = SLACK_TOKEN
end

headers = {
"Content-type" => "application/json; charset=utf-8",
"Authorization" => "Bearer #{token}"
}

uri = URI(url)
https = Net::HTTP.new(uri.host, uri.port)
https.use_ssl = true
req = Net::HTTP::Post.new(url, headers)
req.body = payload.to_json if payload

begin
res = https.request(req)
parsed_res = JSON.parse(res.body)
response = parsed_res

unless response['ok']
opts = {
error_class: "Slack integration [error]",
error_message: parsed_res['error'],
context: {url: url, payload: payload, response: parsed_res}
}
Honeybadger.notify_cronjob_error opts
response = false
end
rescue Exception => error
opts = {
error_class: "Slack integration [error]",
error_message: error,
context: {url: url, payload: payload}
}
Honeybadger.notify_cronjob_error opts
response = false
end
response
end
end

0 comments on commit eac63bc

Please sign in to comment.