Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature support microsoft teams webhooks #585 #612

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ package org.opensearch.notifications.spi.model.destination
* Supported notification destinations
*/
enum class DestinationType {
CHIME, SLACK, CUSTOM_WEBHOOK, SMTP, SES, SNS
CHIME, SLACK, CUSTOM_WEBHOOK, SMTP, SES, SNS, MICROSOFT_TEAMS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.notifications.spi.model.destination
/**
* This class holds the contents of a Microsoft Teams destination
*/
class MicrosoftTeamsDestination(
url: String,
) : WebhookDestination(url, DestinationType.MICROSOFT_TEAMS)
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ internal class ValidationHelpersTests {
private val LOCAL_HOST_EXTENDED = "https://localhost:6060/service"
private val WEBHOOK_URL = "https://test-webhook.com:1234/subdirectory?param1=value1&param2=&param3=value3"
private val CHIME_URL = "https://domain.com/sample_chime_url#1234567890"
private val MICROSOFT_TEAMS_WEBHOOK_URL = "https://{}.webhook.office.com/webhookb2/{}/IncomingWebhook/{}"

private val hostDenyList = listOf(
"127.0.0.0/8",
Expand Down Expand Up @@ -94,7 +95,10 @@ internal class ValidationHelpersTests {
fun `validator identifies webhook url as valid`() {
assert(isValidUrl(WEBHOOK_URL))
}

@Test
fun `validator identifies webhook url as valid`() {
assert(isValidUrl(MICROSOFT_TEAMS_WEBHOOK_URL))
}
@Test
fun `validator identifies chime url as valid`() {
assert(isValidUrl(CHIME_URL))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ internal object DestinationTransportProvider {
DestinationType.CUSTOM_WEBHOOK to webhookDestinationTransport,
DestinationType.SMTP to smtpDestinationTransport,
DestinationType.SNS to snsDestinationTransport,
DestinationType.SES to sesDestinationTransport
DestinationType.SES to sesDestinationTransport,
DestinationType.MICROSOFT_TEAMS to webhookDestinationTransport
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class NotificationCoreImplTests {
"sns",
"ses_account",
"smtp_account",
"email_group"
"email_group",
"microsoft_teams"
)
private val defaultConfigFeatures = listOf(
"alerting",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.notifications.core.destinations

internal class MicrosofTeamsDestinationTests {
companion object {
@JvmStatic
fun escapeSequenceToRaw(): Stream<Arguments> =
Stream.of(
Arguments.of("\n", """\n"""),
Arguments.of("\t", """\t"""),
Arguments.of("\b", """\b"""),
Arguments.of("\r", """\r"""),
Arguments.of("\"", """\""""),
)
}

@BeforeEach
fun setup() {
// Stubbing isHostInDenylist() so it doesn't attempt to resolve hosts that don't exist in the unit tests
mockkStatic("org.opensearch.notifications.spi.utils.ValidationHelpersKt")
every { org.opensearch.notifications.spi.utils.isHostInDenylist(any(), any()) } returns false
}

@Test
fun `test teams message null entity response`() {
val mockHttpClient = mockk<CloseableHttpClient>()

// The DestinationHttpClient replaces a null entity with "{}".
val expectedWebhookResponse = DestinationMessageResponse(RestStatus.OK.status, "{}")
// TODO replace EasyMock in all UTs with mockk which fits Kotlin better
val httpResponse = mockk<CloseableHttpResponse>()
every { mockHttpClient.execute(any<HttpPost>()) } returns httpResponse

every { httpResponse.code } returns RestStatus.OK.status
every { httpResponse.entity } returns null

val httpClient = DestinationHttpClient(mockHttpClient)
val webhookDestinationTransport = WebhookDestinationTransport(httpClient)
DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.TEAMS to webhookDestinationTransport)

val title = "test Teams"
val messageText = "Message gughjhjlkh Body emoji test: :) :+1: " +
"link test: http://sample.com email test: [email protected] All member call out: " +
"@All All Present member call out: @Present"
val url = "https://8m7xqz.webhook.office.com/webhookb2/b0885113-57f8-4b61-8f3a-bdf3f4ae2831@500d1839-8666-4320-9f55-59d8838ad8db/IncomingWebhook/84637be48f4245c09b82e735b2cd9335/b7e1bf56-6634-422c-abe8-402e6e95fc68"

val destination = MicrosoftTeamsDestination(url)
val message = MessageContent(title, messageText)

val actualTeamsResponse: DestinationMessageResponse = NotificationCoreImpl.sendMessage(destination, message, "referenceId")

assertEquals(expectedWebhookResponse.statusText, actualTeamsResponse.statusText)
assertEquals(expectedWebhookResponse.statusCode, actualTeamsResponse.statusCode)
}

@Test
fun `test teams message empty entity response`() {
val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java)
val expectedWebhookResponse = DestinationMessageResponse(RestStatus.OK.status, "")

// TODO replace EasyMock in all UTs with mockk which fits Kotlin better
val httpResponse = mockk<CloseableHttpResponse>()
every { mockHttpClient.execute(any<HttpPost>()) } returns httpResponse

every { httpResponse.code } returns RestStatus.OK.status
every { httpResponse.entity } returns StringEntity(responseContent)
EasyMock.replay(mockHttpClient)

val httpClient = DestinationHttpClient(mockHttpClient)
val webhookDestinationTransport = WebhookDestinationTransport(httpClient)
DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.CHIME to webhookDestinationTransport)

val title = "test microsoft Teams "
val messageText = "{\"Content\":\"Message gughjhjlkh Body emoji test: :) :+1: " +
"link test: http://sample.com email test: [email protected] All member call out: " +
"@All All Present member call out: @Present\"}"
val url = "https://8m7xqz.webhook.office.com/webhookb2/b0885113-57f8-4b61-8f3a-bdf3f4ae2831@500d1839-8666-4320-9f55-59d8838ad8db/IncomingWebhook/84637be48f4245c09b82e735b2cd9335/b7e1bf56-6634-422c-abe8-402e6e95fc68"

val destination = MicrosoftTeamsDestination(url)
val message = MessageContent(title, messageText)

val actualChimeResponse: DestinationMessageResponse = NotificationCoreImpl.sendMessage(destination, message, "referenceId")

assertEquals(expectedWebhookResponse.statusText, actualChimeResponse.statusText)
assertEquals(expectedWebhookResponse.statusCode, actualChimeResponse.statusCode)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ enum class Metrics(val metricName: String, val counter: Counter<*>) {
"notifications.message_destination.email",
BasicCounter()
),
NOTIFICATIONS_MESSAGE_DESTINATION_MICROSOFT_TEAMS(
"notifications.message_destination.microsofTeams",
BasicCounter()
),
NOTIFICATIONS_MESSAGE_DESTINATION_SES_ACCOUNT(
"notifications.message_destination.ses_account", BasicCounter()
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import org.opensearch.notifications.spi.model.MessageContent
import org.opensearch.notifications.spi.model.destination.BaseDestination
import org.opensearch.notifications.spi.model.destination.ChimeDestination
import org.opensearch.notifications.spi.model.destination.CustomWebhookDestination
import org.opensearch.notifications.spi.model.destination.MicrosoftTeamsDestination
import org.opensearch.notifications.spi.model.destination.SesDestination
import org.opensearch.notifications.spi.model.destination.SlackDestination
import org.opensearch.notifications.spi.model.destination.SmtpDestination
Expand Down Expand Up @@ -226,12 +227,12 @@ object SendMessageActionHelper {
ConfigType.NONE -> null
ConfigType.SLACK -> sendSlackMessage(configData as Slack, message, eventStatus, eventSource.referenceId)
ConfigType.CHIME -> sendChimeMessage(configData as Chime, message, eventStatus, eventSource.referenceId)
ConfigType.WEBHOOK -> sendWebhookMessage(
configData as Webhook,
message,
eventStatus,
eventSource.referenceId
)
// ConfigType.WEBHOOK -> sendWebhookMessage(
// configData as Webhook,
// message,
// eventStatus,
// eventSource.referenceId
// )
ConfigType.EMAIL -> sendEmailMessage(
user,
configData as Email,
Expand All @@ -240,6 +241,7 @@ object SendMessageActionHelper {
eventStatus,
eventSource.referenceId
)
ConfigType.WEBHOOK -> sendMicrosoftTeamsMessage(configData as Webhook, message, eventStatus, eventSource.referenceId)
ConfigType.SES_ACCOUNT -> null
ConfigType.SMTP_ACCOUNT -> null
ConfigType.EMAIL_GROUP -> null
Expand Down Expand Up @@ -336,6 +338,12 @@ object SendMessageActionHelper {
LegacyDestinationResponse.Builder().withStatusCode(400)
.withResponseContent("Channel type given (sns) for publishing to legacy destination not supported").build()
}
// LegacyDestinationType.LEGACY_CUSTOM_WEBHOOK -> {
// val destination = MicrosoftTeamsDestination(baseMessage.url)
// val status = sendMessageThroughSpi(destination, message, "legacy")
// LegacyDestinationResponse.Builder().withStatusCode(status.statusCode)
// .withResponseContent(status.statusText).build()
// }
null -> {
log.warn("No channel type given (null) for publishing to legacy destination")
LegacyDestinationResponse.Builder().withStatusCode(400)
Expand Down Expand Up @@ -584,6 +592,20 @@ object SendMessageActionHelper {
DestinationMessageResponse(RestStatus.FAILED_DEPENDENCY.status, "Failed to send notification")
}
}
/**
* Send message to destination using microsoftTeams
*/
private fun sendMicrosoftTeamsMessage(
webhook: Webhook,
message: MessageContent,
eventStatus: EventStatus,
referenceId: String
): EventStatus {
Metrics.NOTIFICATIONS_MESSAGE_DESTINATION_MICROSOFT_TEAMS.counter.increment()
val destination = MicrosoftTeamsDestination(webhook.url)
val status = sendMessageThroughSpi(destination, message, referenceId)
return eventStatus.copy(deliveryStatus = DeliveryStatus(status.statusCode.toString(), status.statusText))
}

/**
* Collects all child configs of the channel configurations (like email)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,38 @@ internal class SendTestMessageRestHandlerIT : PluginRestTestCase() {
val error = sendResponse.get("error").asJsonObject
Assert.assertNotNull(error.get("reason").asString)
}
@Suppress("EmptyFunctionBlock")
fun `test send test microsoft Teams message`() {
// Create webhook notification config
val createRequestJsonString = """
{
"config":{
"name":"this is a sample config name",
"description":"this is a sample config description",
"config_type":"webhook",
"is_enabled":true,
"chime":{
"url":"https://8m7xqz.webhook.office.com/webhookb2/b0885113-57f8-4b61-8f3a-bdf3f4ae2831@500d1839-8666-4320-9f55-59d8838ad8db/IncomingWebhook/84637be48f4245c09b82e735b2cd9335/b7e1bf56-6634-422c-abe8-402e6e95fc68"
}
}
}
""".trimIndent()
val configId = createConfigWithRequestJsonString(createRequestJsonString)
Assert.assertNotNull(configId)
Thread.sleep(1000)

// send test message
val sendResponse = executeRequest(
RestRequest.Method.POST.name,
"$PLUGIN_BASE_URI/feature/test/$configId",
"",
RestStatus.INTERNAL_SERVER_ERROR.status
)

// verify failure response is with message
val error = sendResponse.get("error").asJsonObject
Assert.assertNotNull(error.get("reason").asString)
}

@Suppress("EmptyFunctionBlock")
fun `test send test smtp email message`() {
Expand Down