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 @@
+
+
+
+
+
+ <%
+ opts = [
+ :alert_open,
+ :alert_acknowledged,
+ :alert_resolved,
+ :alert_dropped,
+ ]
+ %>
+ <% opts.each do |opt| %>
+
+ <% 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