diff --git a/app/models/pager_tree/integrations/channel/microsoft_teams/v3.rb b/app/models/pager_tree/integrations/channel/microsoft_teams/v3.rb index 123fecd..cee49c8 100644 --- a/app/models/pager_tree/integrations/channel/microsoft_teams/v3.rb +++ b/app/models/pager_tree/integrations/channel/microsoft_teams/v3.rb @@ -24,6 +24,10 @@ class Channel::MicrosoftTeams::V3 < Integration self.option_time_zone ||= "UTC" end + def converts_to + "PagerTree::Integrations::Channel::MicrosoftTeams::V4" + end + def adapter_supports_incoming? false end diff --git a/app/models/pager_tree/integrations/channel/microsoft_teams/v4.rb b/app/models/pager_tree/integrations/channel/microsoft_teams/v4.rb new file mode 100644 index 0000000..75c8378 --- /dev/null +++ b/app/models/pager_tree/integrations/channel/microsoft_teams/v4.rb @@ -0,0 +1,218 @@ +module PagerTree::Integrations + class Channel::MicrosoftTeams::V4 < Integration + OPTIONS = [ + {key: :incoming_webhook_url, type: :string, default: nil}, + {key: :alert_open, type: :boolean, default: false}, + {key: :alert_acknowledged, type: :boolean, default: false}, + {key: :alert_resolved, type: :boolean, default: false}, + {key: :alert_dropped, type: :boolean, default: false}, + {key: :outgoing_rules, type: :string, default: nil}, + {key: :time_zone, type: :string, default: nil} + ] + store_accessor :options, *OPTIONS.map { |x| x[:key] }.map(&:to_s), prefix: "option" + + validates :option_incoming_webhook_url, presence: true, url: {no_local: true} + validate :validate_time_zone_exists + + after_initialize do + self.option_incoming_webhook_url ||= nil + self.option_alert_open ||= false + self.option_alert_acknowledged ||= false + self.option_alert_resolved ||= false + self.option_alert_dropped ||= false + self.option_outgoing_rules ||= "" + self.option_time_zone ||= "UTC" + end + + def adapter_supports_incoming? + false + end + + def adapter_supports_outgoing? + true + end + + def adapter_show_outgoing_webhook_delivery? + true + end + + def adapter_supports_title_template? + false + end + + def adapter_supports_description_template? + false + end + + def adapter_supports_auto_aggregate? + false + end + + def adapter_supports_auto_resolve? + false + end + + def adapter_outgoing_interest?(event_name) + try("option_#{event_name}") || false + end + + def adapter_process_outgoing + url = adapter_outgoing_event.outgoing_rules_data.dig("webhook_url") || self.option_incoming_webhook_url + body = _blocks.merge(adapter_outgoing_event.outgoing_rules_data.except("webhook_url")) + + outgoing_webhook_delivery = OutgoingWebhookDelivery.factory( + resource: self, + url: url, + body: body + ) + outgoing_webhook_delivery.save! + outgoing_webhook_delivery.deliver_later + + outgoing_webhook_delivery + end + + private + + def _alert + @_alert ||= adapter_outgoing_event.alert + end + + def _blocks + { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: nil, + content: { + type: "AdaptiveCard", + body: [ + { + type: "Container", + backgroundImage: _color, + items: [ + { + type: "TextBlock", + size: "Large", + weight: "Bolder", + text: _title + }, + { + type: "ColumnSet", + columns: [ + { + type: "Column", + items: [ + { + type: "TextBlock", + weight: "Bolder", + text: _title, + wrap: true + }, + { + type: "TextBlock", + spacing: "None", + text: "Created #{_alert.created_at.in_time_zone(option_time_zone).iso8601}", + wrap: true + } + ], + width: "stretch" + } + ] + } + ] + }, + { + type: "Container", + items: [ + { + type: "FactSet", + facts: [ + { + title: "Status:", + value: _alert.status&.upcase + }, { + title: "Urgency:", + value: _alert.urgency&.upcase + }, { + title: "Source:", + value: _alert.source&.name + }, { + title: "Destinations:", + value: _alert.alert_destinations&.map { |d| d.destination.name }&.join(", ") + }, { + title: "User:", + value: _alert.alert_responders&.where(role: :incident_commander)&.includes(account_user: :user)&.first&.account_user&.name + } + ], + spacing: "None" + } + ], + spacing: "Medium" + }, + { + type: "Container", + items: [ + { + type: "TextBlock", + text: _alert.description&.try(:to_plain_text), + wrap: true, + separator: true, + color: "Light" + }, + { + type: "FactSet", + facts: _alert.additional_data&.map { |ad| {title: ad["label"], value: ad["value"]} } || [], + spacing: "Medium", + separator: true + } + ], + spacing: "Medium", + separator: true + } + ], + actions: [ + { + type: "Action.OpenUrl", + title: "View", + url: Rails.application.routes.url_helpers.try(:alert_url, _alert, script_name: "/#{_alert.account_id}"), + style: "positive" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.2" + } + } + ] + } + end + + def _title + return @_title if @_title.present? + + @_title = if _alert.incident? + "Incident ##{_alert.tiny_id} [#{_alert.incident_severity.upcase.dasherize}] #{_alert.incident_message} - #{_alert.title}" + else + "Alert ##{_alert.tiny_id} #{_alert.title}" + end + end + + def _color + case _alert.status + when "open", "dropped" + "https://pagertree.com/assets/img/icon/red-square.png" + when "acknowledged" + "https://pagertree.com/assets/img/icon/yellow-square.png" + when "resolved" + "https://pagertree.com/assets/img/icon/green-square.png" + else + "https://pagertree.com/assets/img/icon/grey-square.png" + end + end + + def validate_time_zone_exists + return if option_time_zone.present? && ActiveSupport::TimeZone[option_time_zone].present? + errors.add(:option_time_zone, "does not exist") + end + end +end diff --git a/app/models/pager_tree/integrations/integration.rb b/app/models/pager_tree/integrations/integration.rb index adcd321..8702f90 100644 --- a/app/models/pager_tree/integrations/integration.rb +++ b/app/models/pager_tree/integrations/integration.rb @@ -29,6 +29,9 @@ class Integration < PagerTree::Integrations.integration_parent_class.constantize # the outgoing event attribute :adapter_outgoing_event + def converts_to + end + # START basic incoming functions def adapter_supports_incoming? false diff --git a/app/views/pager_tree/integrations/channel/microsoft_teams/v4/_form_options.html.erb b/app/views/pager_tree/integrations/channel/microsoft_teams/v4/_form_options.html.erb new file mode 100644 index 0000000..0309c28 --- /dev/null +++ b/app/views/pager_tree/integrations/channel/microsoft_teams/v4/_form_options.html.erb @@ -0,0 +1,40 @@ +
+
+ <%= form.label :option_incoming_webhook_url %> + <%= form.text_field :option_incoming_webhook_url, class: "form-control" %> +

<%== t("pager_tree.integrations.common.option_incoming_webhook_url_hint_html") %>

+
+ +
+ <%= form.label :option_time_zone %> + <%= form.time_zone_select :option_time_zone, nil, {default: "UTC"}, { class: "form-control", data: { select_target: "select" }} %> +

<%== t(".option_time_zone_hint_html") %>

+
+ + <% + opts = [ + :alert_open, + :alert_acknowledged, + :alert_resolved, + :alert_dropped, + ] + %> + <% opts.each do |opt| %> +
+ <%= form.check_box "option_#{opt.to_s}".to_sym, class: "form-checkbox" %> + <%= form.label "option_#{opt.to_s}".to_sym, class: "inline-block" %> +

<%== t("pager_tree.integrations.common.option_#{opt.to_s}_hint_html") %>

+
+ <% end %> + +
+ + +
+ <%= tag.div class: "form-group group", data: {controller: "code-editor", code_editor_language_value: "yaml", code_editor_read_only_value: false } do %> + <%= form.label :option_outgoing_rules, t("pager_tree.integrations.common.option_outgoing_rules") %> + <%= form.hidden_field :option_outgoing_rules, class: "form-control", data: {code_editor_target: "form"} %> + <%= tag.div class: "h-96", data: {code_editor_target: "editor"} do %><%= form.object.option_outgoing_rules %><% end %> +

<%== t("pager_tree.integrations.common.option_outgoing_rules_hint_html") %>

+ <% end %> +
diff --git a/app/views/pager_tree/integrations/channel/microsoft_teams/v4/_show_options.html.erb b/app/views/pager_tree/integrations/channel/microsoft_teams/v4/_show_options.html.erb new file mode 100644 index 0000000..0793999 --- /dev/null +++ b/app/views/pager_tree/integrations/channel/microsoft_teams/v4/_show_options.html.erb @@ -0,0 +1,53 @@ +
+
+ <%= t("activerecord.attributes.pager_tree/integrations/integration.option_incoming_webhook_url") %> +
+
+
+

+ <%= integration.option_incoming_webhook_url %> +

+
+
+
+ +
+
+ <%= t("activerecord.attributes.pager_tree/integrations/channel/microsoft_teams/v3.option_time_zone") %> +
+
+
+

+ <%= integration.option_time_zone %> +

+
+
+
+ +<% + opts = [ + :alert_open, + :alert_acknowledged, + :alert_resolved, + :alert_dropped, + ] +%> +<% opts.each do |opt| %> +
+
+ <%= t("activerecord.attributes.pager_tree/integrations/integration.option_#{opt.to_s}") %> +
+
+ <%= render partial: "shared/components/badge_enabled", locals: { enabled: integration.send("option_#{opt.to_s}") } %> +
+
+<% end %> + +
+
+ <%= t("pager_tree.integrations.common.option_outgoing_rules") %> +
+
+ <%= render partial: "shared/components/badge_enabled", locals: { enabled: integration.option_outgoing_rules.present? } %> +
+
\ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 401e368..bd988c3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -25,6 +25,9 @@ en: v3: form_options: option_time_zone_hint_html: "The time zone to use when formatting dates and times" + v4: + form_options: + option_time_zone_hint_html: "The time zone to use when formatting dates and times" cloudflare: v3: form_options: diff --git a/test/fixtures/pager_tree/integrations/integrations.yml b/test/fixtures/pager_tree/integrations/integrations.yml index b06c074..cb73ce1 100644 --- a/test/fixtures/pager_tree/integrations/integrations.yml +++ b/test/fixtures/pager_tree/integrations/integrations.yml @@ -243,6 +243,17 @@ channel_microsoft_teams_v3: outgoing_rules: "" time_zone: "Pacific Time (US & Canada)" +channel_microsoft_teams_v4: + type: "PagerTree::Integrations::Channel::MicrosoftTeams::V4" + options: + incoming_webhook_url: "https://statuscode.app/200" + alert_open: false + alert_acknowledged: false + alert_resolved: false + alert_dropped: false + outgoing_rules: "" + time_zone: "Pacific Time (US & Canada)" + channel_slack_v3: type: "PagerTree::Integrations::Channel::Slack::V3" diff --git a/test/models/pager_tree/integrations/channel/microsoft_teams/v4_test.rb b/test/models/pager_tree/integrations/channel/microsoft_teams/v4_test.rb new file mode 100644 index 0000000..ad8bb3d --- /dev/null +++ b/test/models/pager_tree/integrations/channel/microsoft_teams/v4_test.rb @@ -0,0 +1,214 @@ +require "test_helper" + +module PagerTree::Integrations + class Channel::MicrosoftTeams::V4Test < ActiveSupport::TestCase + include Integrateable + include ActiveJob::TestHelper + + setup do + @integration = pager_tree_integrations_integrations(:channel_microsoft_teams_v4) + + @alert = JSON.parse({ + id: "01G9ZET2HZSTA9B0YDAB9G7XPZ", + account_id: "01G9ZDGQ0NYAF6E1M3C6FAYDV5", + prefix_id: "alt_K22OuvPYNmCyvJ", + tiny_id: 22, + source: { + name: "Joe Bob" + }, + title: "new alert", + status: "acknowledged", + urgency: "medium", + created_at: "2022-08-08T19:27:20.127Z", + updated_at: "2022-08-08T19:27:49.256Z", + incident: false, + incident_severity: "sev_1", + incident_message: "", + alert_destinations: [ + { + destination: { + name: "Team Bobcats" + } + } + ] + }.to_json, object_class: OpenStruct) + + @alert.created_at = @alert.created_at.to_datetime + @alert.updated_at = @alert.updated_at.to_datetime + + @webhook_url = "https://webhook.example.com" + + @data = { + event_name: :alert_acknowledged, + alert: @alert, + changes: [{ + before: { + status: "open" + }, + after: { + foo: "ackowledged" + } + }], + outgoing_rules_data: {} + } + + @expected_payload = { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: nil, + content: { + type: "AdaptiveCard", + body: [ + { + type: "Container", + backgroundImage: "https://pagertree.com/assets/img/icon/yellow-square.png", + items: [ + { + type: "TextBlock", + size: "Large", + weight: "Bolder", + text: "Alert ##{@alert.tiny_id} #{@alert.title}" + }, + { + type: "ColumnSet", + columns: [ + { + type: "Column", + items: [ + { + type: "TextBlock", + weight: "Bolder", + text: "Alert ##{@alert.tiny_id} #{@alert.title}", + wrap: true + }, + { + type: "TextBlock", + spacing: "None", + text: "Created #{@alert.created_at.in_time_zone(@integration.option_time_zone).iso8601}", + wrap: true + } + ], + width: "stretch" + } + ] + } + ] + }, + { + type: "Container", + items: [ + { + type: "FactSet", + facts: [ + { + title: "Status:", + value: @alert.status&.upcase + }, { + title: "Urgency:", + value: @alert.urgency&.upcase + }, { + title: "Source:", + value: @alert.source&.name + }, { + title: "Destinations:", + value: @alert.alert_destinations&.map { |d| d.destination.name }&.join(", ") + }, { + title: "User:", + value: @alert.alert_responders&.where(role: :incident_commander)&.includes(account_user: :user)&.first&.account_user&.name + } + ], + spacing: "None" + } + ], + spacing: "Medium" + }, + { + type: "Container", + items: [ + { + type: "TextBlock", + text: @alert.description&.try(:to_plain_text), + wrap: true, + separator: true, + color: "Light" + }, + { + type: "FactSet", + facts: @alert.additional_data&.map { |ad| {title: ad["label"], value: ad["value"]} } || [], + spacing: "Medium", + separator: true + } + ], + spacing: "Medium", + separator: true + } + ], + actions: [ + { + type: "Action.OpenUrl", + title: "View", + url: Rails.application.routes.url_helpers.try(:alert_url, @alert, script_name: "/#{@alert.account_id}"), + style: "positive" + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.2" + } + } + ] + } + end + + test "sanity" do + assert_not @integration.adapter_supports_incoming? + assert @integration.adapter_incoming_can_defer? + assert @integration.adapter_supports_outgoing? + assert_not @integration.adapter_show_alerts? + assert @integration.adapter_show_logs? + assert @integration.adapter_show_outgoing_webhook_delivery? + end + + test "outgoing_interest" do + assert_not @integration.option_alert_open + assert_not @integration.adapter_outgoing_interest?(:alert_open) + @integration.option_alert_open = true + assert @integration.adapter_outgoing_interest?(:alert_open) + end + + test "can_process_outgoing" do + assert_no_performed_jobs + + @integration.adapter_outgoing_event = OutgoingEvent.new(**@data) + outgoing_webhook_delivery = @integration.adapter_process_outgoing + + assert_enqueued_jobs 1 + + assert_equal @integration.option_incoming_webhook_url, outgoing_webhook_delivery.url + assert_equal :queued.to_s, outgoing_webhook_delivery.status + assert_equal @expected_payload.to_json, outgoing_webhook_delivery.body.to_json + end + + test "respects outgoing rules data" do + assert_no_performed_jobs + + @data[:outgoing_rules_data] = { + webhook_url: @webhook_url, + extra: true + }.with_indifferent_access + + @integration.adapter_outgoing_event = OutgoingEvent.new(**@data) + outgoing_webhook_delivery = @integration.adapter_process_outgoing + + assert_enqueued_jobs 1 + + assert_equal @webhook_url, outgoing_webhook_delivery.url + assert_equal :queued.to_s, outgoing_webhook_delivery.status + + @expected_payload[:extra] = true + + assert_equal @expected_payload.to_json, outgoing_webhook_delivery.body.to_json + end + end +end