require 'json' require 'interrupt_helper/pager_duty' module Lita module Handlers class Interrupt < Handler config :pagerduty_teams, required: true, type: Hash # { lowercase('team') => 'token' } config :integration_name, type: String, default: 'lita-interrupt'.freeze # Keys in Redis CALL_ALIASES = 'call_alias'.freeze # Constants used by the PagerDuty API EVENTS_API_V2_INBOUND = 'events_api_v2_inbound_integration'.freeze include ::InterruptHelper::PagerDuty ## ROUTES route( /^call\s+(?[^\s]+)(?:\s+(?.+))?\s*$/i, :call_alias, command: true, help: { t('help.call.syntax') => t('help.call.desc') } ) route( /^int\s+team\s+ls\s*$/i, :list_teams, restrict_to: %w[pagerduty_admins], command: true, help: { t('help.team_ls.syntax') => t('help.team_ls.desc') } ) route( /^int\s+alias\s+new\s+(?\S+)\s+(?\S+)\s+(?\S+)\s*$/i, :create_alias, command: true, restrict_to: %w[pagerduty_admins], help: { t('help.alias_new.syntax') => t('help.alias_new.desc') } ) route( /^int\s+alias\s+rm\s+(?\S+)\s*$/i, :remove_alias, command: true, restrict_to: %w[pagerduty_admins], help: { t('help.alias_rm.syntax') => t('help.alias_rm.desc') } ) route( /^(int\s+alias\s+ls|who\s+can\s+i\s+call\??)\s*$/i, :list_aliases, command: true, help: { t('help.alias_ls.syntax') => t('help.alias_ls.desc') } ) route( /^int\s+service\s+ls(\s+(?\S+)(\s+(?.+))?)?\s*$/i, :list_services, command: true, restrict_to: %w[pagerduty_admins], help: { t('help.service_ls.syntax') => t('help.service_ls.desc') } ) ## CALLBACKS # @param [Lita::Response] response def call_alias(response) alias_id = response.match_data['alias_id'].downcase callee = get_call_alias(alias_id) return response.reply(t('call.no_alias', name: alias_id)) unless callee request = gen_call_request(response, callee['integration']['integration_key']) events_v2.post('enqueue', request) response.reply(t('call.ok', name: alias_id)) rescue APIError => ae log_exception(__callee__, ex) response.reply(t('call.api_error', id: alias_id, message: ae.message)) rescue Exception => ex send_exception(response, __callee__, ex) end # @param [Lita::Response] response def list_teams(response) names = config.pagerduty_teams.keys if names.empty? response.reply(t('team_ls.no_teams')) return end response.reply( names.sort.map do |team_id| t('team_ls.entry', team_id: team_id) end.join("\n") ) rescue Exception => ex send_exception(response, __callee__, ex) end # @param [Lita::Response] response def create_alias(response) alias_id = response.match_data['alias_id'].downcase team_id = response.match_data['team_id'].downcase service_id = response.match_data['service_id'] pd = team(team_id) if pd.nil? response.reply(t('alias_new.no_team', team_id: team_id)) return end integration = get_events_api_integration(pd, service_id) unless integration return response.reply(t('alias_new.no_integration', service_id: service_id, integration_name: config.integration_name)) end set_call_alias(alias_id, team_id, service_id, integration) response.reply(t('alias_new.ok', id: alias_id)) rescue Exception => ex send_exception(response, __callee__, ex) end # @param [Lita::Response] response def list_aliases(response) aliases = redis.hgetall(CALL_ALIASES) if aliases.empty? response.reply(t('alias_ls.no_aliases')) return end response.reply( aliases.keys.sort.map do |alias_id| obj = MultiJson.load(aliases[alias_id]) t( 'alias_ls.entry', alias_id: alias_id, service_name: obj['integration']['service']['summary'], team_id: obj['team_id'] ) end.join("\n") ) rescue Exception => ex send_exception(response, __callee__, ex) end # @param [Lita::Response] response def remove_alias(response) alias_id = response.match_data['alias_id'].downcase if redis.hdel(CALL_ALIASES, alias_id) > 0 then response.reply(t('alias_rm.ok', id: alias_id)) else response.reply(t('alias_rm.no_alias', id: alias_id)) end rescue Exception => ex send_exception(response, __callee__, ex) end # @param [Lita::Response] response def list_services(response) query = response.match_data['query'].to_s.strip team_id = response.match_data['team_id'].to_s team_ids = case team_id when "" then config.pagerduty_teams.keys.sort else [team_id] end response.reply( team_ids.flat_map do |team_id; services| services = list_team_services(team_id, query) header = t('service_ls.team', team_id: team_id) case services when nil break [t('service_ls.no_team', team_id: team_id)] when [] [header, t('service_ls.no_services')] else [header, *services] end end.join("\n") ) rescue APIError => ae log_exception(__callee__, ex) response.reply(t('call.api_error', id: alias_id, message: ae.message)) rescue Exception => ex send_exception(response, __callee__, ex) end def list_team_services(team_id, query) pd = team(team_id) return nil if pd.nil? params = { query: query, include: %w[integrations] } results = pd.get('services', query_params: params) # Only list Events API 2-integrated services with suitable integrations services = (results['services'] || []).select do |svc| (svc['integrations'] || []).any? { |i| is_suitable_integration?(i) } end services.sort_by { |svc| svc['name'] }.map { |svc| t('service_ls.entry', service_name: svc['name'], service_id: svc['id']) } end ## IMPLEMENTATION def team(team_id) pagerduty(config.pagerduty_teams[team_id]) end def send_exception(response, callee, ex) log_exception(callee, ex) response.reply(t('exception', handler: callee, class: ex.class.name, message: ex.message)) end def log_exception(callee, ex) log.warn("Exception occurred #{ex.class}: #{ex.message}:\n#{ex.backtrace.join("\n")}") end def get_room_name(response) room = Lita::Room.find_by_id(response.message.source.room_object.id) room.name || room.mention_name if room end def gen_call_request(response, routing_key) # Build summary message sent in the event room_name = get_room_name(response) || '' room_note = room_name.empty? ? '' : " in ##{room_name}" msg = "You've been called by #{response.user.name}#{room_note}." sub_msg = (response.match_data['message'] || '').strip unless sub_msg.empty? sub_msg = "#{sub_msg}." unless /\p{Terminal_Punctuation}["']?$/ =~ sub_msg msg = "#{msg}\n#{sub_msg}" end source = 'Slack' source = "##{room_name}" unless room_name.empty? { routing_key: routing_key, event_action: 'trigger', payload: { summary: msg, source: source, severity: 'critical', component: 'Human', group: 'On-Call', class: 'Notification' } } end # @return [Hash] A hash describing the policy alias. If no policy alias is defined, returns nil. def get_call_alias(alias_id) callee_json = redis.hget(CALL_ALIASES, alias_id) return nil if callee_json.nil? MultiJson.load(callee_json) end def set_call_alias(alias_id, team_id, service_id, integration) obj = { version: 1, team_id: team_id.to_s, alias_id: alias_id.to_s, service_id: service_id, integration: integration } redis.hset(CALL_ALIASES, alias_id.downcase, MultiJson.dump(obj)) end def get_events_api_integration(pd, service_id) service = pd.get( "services/#{service_id}", query_params: { include: %w[integrations] } )&.fetch('service') return nil unless service index = service['integrations'].index do |int| int['type'] == EVENTS_API_V2_INBOUND && int['name'].downcase.gsub(/[_.\s]/, '-') == config.integration_name end index && service['integrations'][index] end def required_integration_name @integration_name ||= normalize_name(config.integration_name) end def normalize_name(name) name.downcase.gsub(/[\s\._]/, '-') end def is_suitable_integration?(integration) integration['type'] == EVENTS_API_V2_INBOUND && normalize_name(integration['name']) == required_integration_name end Lita.register_handler(self) end end end