diff --git a/aws/ci_build b/aws/ci_build index 466f758acab55..8cb1e7d50aac1 100755 --- a/aws/ci_build +++ b/aws/ci_build @@ -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 ') # 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 diff --git a/bin/dotd b/bin/dotd index 123a27aa0d114..082c7df9abec6 100755 --- a/bin/dotd +++ b/bin/dotd @@ -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. @@ -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") diff --git a/config.yml.erb b/config.yml.erb index dbd5d19dcee9d..ae8a42b4e3de8 100644 --- a/config.yml.erb +++ b/config.yml.erb @@ -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. diff --git a/lib/cdo/chat_client.rb b/lib/cdo/chat_client.rb index e91aa2710b792..d2a01eca9bad6 100644 --- a/lib/cdo/chat_client.rb +++ b/lib/cdo/chat_client.rb @@ -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 diff --git a/lib/cdo/slack.rb b/lib/cdo/slack.rb index bb53f2408ea34..40ebf162a5636 100644 --- a/lib/cdo/slack.rb +++ b/lib/cdo/slack.rb @@ -1,6 +1,8 @@ +require 'uri' require 'net/http' -require 'open-uri' require 'retryable' +require 'json' +require 'cdo/honeybadger' class Slack COLOR_MAP = { @@ -17,7 +19,20 @@ 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 '@'. @@ -25,14 +40,21 @@ class Slack # @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) @@ -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. @@ -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 @@ -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]}" @@ -148,46 +151,31 @@ 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. @@ -195,17 +183,14 @@ def self.join_room(name) # @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 @@ -226,4 +211,56 @@ def self.join_room(name) gsub(/<\/a>/, '>'). gsub(//, "\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 diff --git a/lib/test/cdo/test_slack.rb b/lib/test/cdo/test_slack.rb index 67a03c7ce6d7b..732de88b16804 100644 --- a/lib/test/cdo/test_slack.rb +++ b/lib/test/cdo/test_slack.rb @@ -14,39 +14,37 @@ def setup end def test_get_topic - Slack.expects(:open).returns( - stub( - read: { - 'ok' => true, - 'channel' => { - 'topic' => { - 'value' => FAKE_TOPIC - } + Slack.expects(:post_to_slack).returns( + { + 'ok' => true, + 'channel' => { + 'topic' => { + 'value' => FAKE_TOPIC } - }.to_json - ) + } + } ) actual_topic = Slack.get_topic FAKE_CHANNEL assert_equal FAKE_TOPIC, actual_topic end def test_get_topic_with_error_response - Slack.expects(:open).returns(stub(read: {'ok' => false}.to_json)) + Slack.stubs(:post_to_slack).returns(false) assert_nil Slack.get_topic FAKE_CHANNEL end def test_update_topic - Slack.expects(:open).returns(stub(read: {'ok' => true}.to_json)) + Slack.stubs(:post_to_slack).returns({'ok' => true}) assert Slack.update_topic(FAKE_CHANNEL, FAKE_TOPIC) end def test_join_room - Slack.expects(:open).returns(stub(read: {'ok' => true}.to_json)) + Slack.stubs(:post_to_slack).returns({'ok' => true}) assert Slack.join_room(FAKE_CHANNEL) end def test_update_topic_with_error_response - Slack.expects(:open).returns(stub(read: {'ok' => false}.to_json)) + Slack.stubs(:post_to_slack).returns(false) refute Slack.update_topic(FAKE_CHANNEL, FAKE_TOPIC) end