И плюс, и минус любой SaaS системы в том, что она управляется не нами, и мы никак (в большинстве случаев) не можем повлиять на цикл обновлений основного функционала и добавление новых фич. Однако эти обновления могут носить собой как информативный характер и не нести никаких серьезных изменений в функционал, так могут и быть критическими для инфраструктуры, что в свою очередь несет собой дополнительные риски для бизнеса, а стало быть, и для нашего спокойствия, как для IT инженеров все это дело поддерживающих. О том, как получать все необходимые сообщения об обновлениях в Microsoft 365 не устанавливая для этого никаких дополнительных приложений будет эта статья. Из всего что нам понадобится, это зарегестированное приложение для доступа в API в Azure Active Directory, Azure Automation, PowerShell и бот в Телеграм.

Задача:

Написать скрипт, который будет раз в час стучаться в API M365, проверять там наличие новых сообщений и присылать их нам в Teams канал или в Telegram.

Информацию обо всех грядущих изменениях можно получить из двух основных источников:

  1. Microsoft 365 Roadmap

  2. Microsoft 365 Message Center

Чтобы получить информацию из первого варианта, никаких дополнительных учетных записей не требуется, т.к. инфа в открытом виде и имеется RSS feed, что подтолкнуло к написаю простенького скрипта для мониторинга RSS фида и направления его в Telegram канал. Найти этот канал вы можете по этой ссылке.

Стоит заметить, что информация доступная в Roadmap не является исчерпывающей и плюс к тому, обновления не накатываются на все тенанты разом, плюс эффект от обновления на тот или иной тенант может быть разным, что побудило Microsoft запустить Message Center который бы давал информацию об обновлениях конкретно вашего тенанта. Помимо всего прочего, там бывают и сообщения, имеющие общий характер.

Для простоты разработки, был выбран PowerShell, т. к. он нативно поддерживается Azure Automation чуть ли не с самого начала. Чтобы получить доступ к информации внутри тенанта не вводя логин и пароль, нужно зарегистрировать приложение в Azure Active Directory и дать приложению соответствующие права.

1.      Заходим на сайт Azure и в строке поиска вводим Active Directory

AAD в Azure
AAD в Azure

2.      В левом боковом меню в разделе Manage выбираем App registrations

3.      Здесь нажимаем на кнопку New Registration и попадаем в меню создания нового приложения внутри Azure Active Directory

4.      В поле Name необходимо ввести уникальное имя для приложения внутри тенанта, остальные же поля можно оставить как есть.

5.      Нажимаем Register и попадаем в созданное приложение. Здесь присутствуют данные, которые понадобятся нам в дальнейшем, а именно:

  • Application ID

  • Directory ID

Их желательно заранее куда ни будь сохранить.

6.      Для авторизации посредством приложения, нужно сгенерировать Client  Secret, без которого токен не получить. Для этого в левом боковом меню выбираем Certificates & Secrets и нажимаем на New Client Secret. Как понятно из названия здесь так же присутствует возможность авторизации посредством сертификата, но в данном примере будет использоваться именно Client Secret.

7.      В появившемся окне нужно добавить Description к создаваемому секрету и указать его срок жизни.

8.      Value секрета лучше сохранить сразу, ибо после обновления или закрытия данного окна, значение секрета будет увидеть невозможно и придется создавать новый.

9.      Далее необходимо предоставить приложению права читать Message Center. Для этого переходим в меню API Permissions

10.  Здесь присутствуют стандартные права в Graph API User.Read, которые отвечают за доступ к информации об аккаунте авторизовавшегося юзера.

Давайте на этом пункте остановится чуточку подробнее.

В M365 есть два основных вида прав, это Delegated Permissions, которые могут быть использованы только при авторизации в приложении пользователем, и Application Permissions которые можно использовать без авторизации человеком. Это очень полезно, когда необходимо что-то автоматизировать, а у вас в тенанте настроен обязательный MFA для всех учетных записей, однако Application Permissions требуют подтверждение Global администратора. User.Read права доступные по умолчанию как раз первого типа, потому их можно сразу же удалить. Щелкаем на права и нажимаем Remove permission.

Теперь же нужно добавить права для чтения Message Center. Нажимаем Add Permission > Скролим вниз и находим Office 365 Management API

11.  Выбираем Application Permissions > ServiceHealth.Read и нажимаем Add Permissions

12.  Далее если есть роль Global Admin, нажимаем кнопку Grant admin consent, либо просим одобрить права того, у кого эта роль имеется

13.  После получения подтверждения, напротив прав должна появится надпись Granted for <tenant name>

На этом история с регистрацией приложения закончена и можно перейти к скрипту.

Первое что необходимо написать, это функцию для авторизации в M365 API, назовем ее Get-APIToken. Функция должна принимать в себя три значения:

  • Application ID

  • Tenant ID (directory ID)

  • App Secret (Client Secret)

Первые два параметра отображались выше в пункте 5 при создании приложения

Функция представляет собой Rest запрос с определенными параметрами на URL вида:

“https://login.microsoftonline.com/” + $TenantID + “/oauth2/v2.0/token”

В итоге функция будет выглядеть следующим образом

Function Get-ApiToken {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$True)]
        [String]$AppId, 

        [Parameter(Mandatory=$True)]
        [String]$AppSecret,

        [Parameter(Mandatory=$True)]
        [String]$TenantID
    )

    $AuthUrl = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
    $Scope = "https://manage.office.com/.default"

    $Body = @{
        client_id = $AppId
        client_secret = $AppSecret
        scope = $Scope
        grant_type = 'client_credentials'
    }

    $PostSplat = @{
        ContentType = 'application/x-www-form-urlencoded'
        Method = 'POST'
        Body = $Body
        Uri = $AuthUrl
    }

    try {
        Invoke-RestMethod @PostSplat -ErrorAction Stop
    }
    catch {
        Write-Warning "$(Get-Date): Exception was caught: $($_.Exception.Message)" 
    }
}

Теперь можно получить Token и проверить что все прошлые действия были выполнены правильно.

try {
    $Token = Get-ApiToken -AppId $ClientId -AppSecret $ClientSecret -TenantID $TenantId -ErrorAction Stop
    Write-Output "$(Get-Date): Token successfully issued"
}
catch {
    Write-Error "$(Get-Date): Can't get the token!"
    break
}

 

В результате значение токена должно напоминать следующее содержание:

 Для того, чтобы получить сообщения из Message Center понадобится две функции, Get-MCMessages и Get-ApiRequestResult

Начнем с функции Get-ApiRequestResult.

Она будет принимать URL запроса, метод и токен.

Из токена формируется header запроса и все это оформляется в Splat.

Function Get-ApiRequestResult {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$True)]
        [String]$Url,
        
        [Parameter(Mandatory=$True)]
        [String]$Method,
        
        [Parameter(Mandatory=$True)]
        [String]$Token
    )
 
    $Header = @{
        Authorization = "$($Token.token_type) $($Token.access_token)"
    }

    $PostSplat = @{
        ContentType = 'application/json'
        Method = $Method
        Header = $Header
        Uri = $Url
    }

    try {
        Invoke-RestMethod @PostSplat -ErrorAction Stop
    }
    catch {
        $Ex = $_.Exception
        $ErrorResponse = $ex.Response.GetResponseStream()
        $Reader = New-Object System.IO.StreamReader($errorResponse)
        $Reader.BaseStream.Position = 0
        $Reader.DiscardBufferedData()
        $ResponseBody = $Reader.ReadToEnd();
        Write-Output "$(Get-Date): Response content:`n$responseBody" -f Red
        throw Write-Error "$(Get-Date): Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)"
    }
}

Далее необходимо написать функцию для получения сообщений из Message Center.

Для этого нужно сделать Get запрос на адрес https://manage.office.com/api/ServiceComms/Messages

Function Get-MCMessages {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$True)]
        [string]$APIUrl,

        [Parameter(Mandatory=$True)]
        [string]$TenantId
    )

    $ApiVersion = "v1.0"
    $MS_resource = "ServiceComms/Messages?&`$filter=MessageType%20eq%20'MessageCenter'"
    $Uri = "$APIUrl/$ApiVersion/$($TenantId)/$MS_resource"
    
    $Method = "GET"

    try {
        Get-ApiRequestResult -Url $Uri -Token $Token -Method $Method -ErrorAction Stop
        Write-Output "$(Get-Date): New messages successfully collected"
    }
    catch {
        $Ex = $_.Exception
        $ErrorResponse = $ex.Response.GetResponseStream()
        $Reader = New-Object System.IO.StreamReader($errorResponse)
        $Reader.BaseStream.Position = 0
        $Reader.DiscardBufferedData()
        $ResponseBody = $Reader.ReadToEnd();
        Write-Output "$(Get-Date): Response content:`n$responseBody" -f Red
        throw Write-Error "$(Get-Date): Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)"
    }
}

Небольшое отступление

Зачем сам запрос делится на две функции? Все дело в том, что принцип запросов к различным API M365 выглядит по большей части одинаково, отличаются только ссылки куда следует запрос, а также ресурсы, которые идут после основной ссылки. Для того чтобы переиспользовать последние две функции, необходимо только заменить значения в переменной $MS_Resource и $URL в функции Get-MCMessages. Так например можно получить информацию о мобильниках заэнроленных в Intune, изменив имя функции на Get-IntuneManagedDevices, значение переменной MS_Resource на "deviceManagement/managedDevices", и URL на https://graph.microsoft.com, что существенно сэкономит время на будущих скриптах. Ну и соответственно у приложения должны быть права на чтение информации об устройствах

Данные из запроса приходят списком сообщений следующего вида:

Предполагается, что скрипт будет крутиться в Azure Automation, что накладывает определенные ограничения. Например, как-то нужно проверить новые сообщения на предмет «свежести» без сохранения всего этого в базу данных, плюс сообщения имеют свойство обновляться. В сообщении имеется параметр LastUpdatedTime, его мы и будем проверять.

Максимальная частота отработки Runbook  в Azure Automation 1 раз в час, за этот период мы и будем проверять новые сообщения каждый час. Таким образом, нужны две временные метки: время, когда скрипт будет запущен и час назад от этого времени. Получаем переменные:

$CurrentTime = Get-Date
$СontrolTime = ($CurrentTime).AddMinutes(-60)

Получаем список всех сообщений воспользовавшись написанной функцией Get-MCMessages.

$Messages = Get-MCmessages -APIUrl $APIUrl -TenantId $TenantId

Далее извлекаем из всего выше полученного только те сообщения, которые были обновлены за последний час

$NewMessages = $Messages.value | Where-Object {$(Get-date $($_.LastUpdatedTime)) -ge $controlTime}

Необходимо проверить есть ли вообще за последний час новые сообщения

$NewMessagesCount = $NewMessages.id.count

if ($NewMessagesCount -gt 0) {
    Write-Output "$(Get-Date): There are $NewMessagesCount new messages"
}
else {
    Write-Output "$(Get-Date): There is no new messages"
    break
}

И таким образом, если новые сообщения все же присутствуют, тогда нужно обработать полученный массив данных. Для этого прибегаем к перебору массива новых сообщений.

if ($NewMessagesCount -gt 0) {

    foreach ($NewMessage in $NewMessages){

    }

}

Собираем нужные переменные. Для этого понадобится извлечь следующие данные для каждого нового сообщения

$MessagePreview = $NewMessage.Messages.MessageText
$MessageID = $NewMessage.id
$MessageTitle = $NewMessage.Title
$MessageType = $NewMessage.actiontype
$PublishedTime = Get-date $($NewMessage.Messages.publishedTime)
$UpdatedTime = Get-Date $($NewMessage.LastUpdatedTime)

Данные в MessageText попадают в формате html, однако мы знаем, что Telegram умеет далеко не все тэги. По этой причине убираем все те тэги, которые были собраны из полученных сообщений, и оставляем только те, которые Telegram принимает. Для этого создаем функцию Remove-HtmlTags, которая принимала бы в себя html и удаляла все те тэги, которые не поддерживаются.

Для этого внутри функции создаем два одномерных массива и один двумерный. Таким образом у нас есть три категории тегов:

  • Простые тэги - имеющие закрывающий тэг и которые как правило не бывают с дополнительными параметрами внутри. С ними можно использовать регулярные выражения

  • Сложные тэги - не имеющие закрывающий тэг, но которые при этом могут вызвать ошибку форматирования.

  • Тэги и просто символы - которые нужно заменить на что-то иное.

Далее уже в зависимости от того, из какой категории определенный тэг, его нужно определенным способом обработать. В итоге получается следующая функция:

Function Remove-HtmlTags {

    param (
        $Text
    )

    $SimpleTags = @(
        'p',
        'i',
        'span',
        'div',
        'ul',
        'ol',
        'h1',
        'h2',
        'h3',
        'div'
    )

    $TagsToRemove = (
        "\<\/?font[^>]*\>",
        '\<br\s?\/?\>',
        '\&rarr',
        'style=""',
        ' target\=\"_blank\"'
    )

    $TagsToReplace = @(
        @('\[','<b>'),
        @('\]','</b>'),
        @('\<A','<a'),
        @('\<\/A\>','</a>'),
        @('\<img[^>]*\>','[There was an image]'),
        @('&nbsp;',' '),
        @('\<li\>',' -'),
        @('\<\/li\>',"`n")
    )

    foreach($Tag in $SimpleTags){
        $Pattern = "\<\/?$tag\>"
        $Text = $Text -replace $Pattern
    }

    foreach($Tag in $TagsToRemove){
        $Text = $Text -replace $Tag
    }

    foreach($Tag in $TagsToReplace){
        $Text = $Text -replace $Tag

    }
    
    foreach($Tag in $SimpleTags){
        $Pattern = "\<\/?$Tag\>"
        $Text = $Text -replace $Pattern
    }

    $Text
    
}

Телеграм та же не поддерживает изображения прямо в тексте, не используя при этом телеграф, по этой причине ссылки на имейджи оставлять не имеет смысла, однако имеет смысл пользователя предупредить, о том, что где-то в тексте было изображение. Данный скрипт не несет исчерпывающей информации о каком-то сообщении, однако дает представление о том, стоит ли идти ради него на админский портал, или все же нет.

Скрипт в том виде что вы его видите стал таким не сразу, и претерпел за время определенные изменения. Например, было замечено, что Microsoft особо не заморачивается со стандартизацией и форматированием контента своих сообщений, по этой причине доверять отступам и переносам строки в тексте было бы крайне опрометчиво. Это вынудило меня разбить полученный текст сначала по разрывам строк используя закрывающий тэг </p>, а далее обрабатывать его уже как массив, после же все это объединить в одну переменную, но уже с вынужденным переносом строк без html тэгов. Так же нужно удалить лишние пробелы в тексте.

$MessageTextWithHtmlString = $MessagePreview -split ('\<\/p\>')
$FormattedMesssageText = $(Remove-HtmlTags $MessageTextWithHtmlString) -creplace '(?m)^\s*\r?\n',''

Далее нужно собрать шапку сообщения. Для этого выделяем жирным Title и добавляем немного служебной информации о сообщении.

$PublishingInfo = "Published: $PublishedTime `nUpdated: $UpdatedTime"
$TgmMessage = "$BoldMessageTitle `n$MessageDescription `n$PublishingInfo `n$FormattedMesssageText"

Microsoft иногда добавляет еще ссылки на документацию и блог, а так же дату, когда должны быть произведены действия со стороны админа. Этот момент тоже нужно проверить в каждом сообщении.

$MessageActionRequiredByDate = $NewMessage.ActionRequiredByDate
$MessageAdditionalInformation = $NewMessage.ExternalLink
$MessageBlogLink = $NewMessage.BlogLink

if($MessageActionRequiredByDate){
    $TgmMessage += "`n<b>Action required by date: </b> $MessageActionRequiredByDate"
}elseif ($MessageAdditionalInformation) {
    $TgmMessage += "`n<a href='$MessageAdditionalInformation'>Additional info</a>"
}elseif ($MessageBlogLink) {
    $TgmMessage += "`n<a href='$MessageBlogLink'>Blog</a>"
}

Теперь полученный текст нужно как то отправить в телеграм. Как регистрировать бота и создавать канал описывать здесь излишне, поэтому перейдем к написанию функции отправки сообщений.

Функция должна принять ChatID, Token, ParsingType, на случай если функцию нужно будет где-то использовать еще, ну и сам текст сообщения.

function Send-TelegramMessage {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [ValidateSet("html","markdown")]
        [String]$ParsingType,

        [Parameter(Mandatory=$true)]
        [String]$MessageText,
        
        [Parameter(Mandatory=$true)]
        [String]$TokenTelegram, 
        
        [Parameter(Mandatory=$true)]
        [String]$ChatID
    )

    $URL_set = "https://api.telegram.org/bot$TokenTelegram/sendMessage"

    $Body = @{
        text = $MessageText
        parse_mode = $ParsingType
        chat_id = $chatID
    }

    $MessageJson = $body | ConvertTo-Json

    try {
        Invoke-RestMethod $URL_set -Method Post -ContentType 'application/json; charset=utf-8' -Body $MessageJson -ErrorAction Stop
        Write-Output "$(Get-Date): Message has been sent"
    }
    catch {
        Write-Error "$(Get-Date): Can't sent message"
        Write-Output "$(Get-Date): StatusCode:" $_.Exception.Response.StatusCode.value__ 
        Write-Output "$(Get-Date): StatusDescription:" $_.Exception.Response.StatusDescription
        throw
    }
    
}

И, собственно, сама отправка сообщения:

Send-TelegramMessage -MessageText $TgmMessage -TokenTelegram $TokenTelegram -ChatID $chatID -ParsingType 'html'

В итоге мы получаем сообщения вида:

Что касается такой служебной информации как токены, чат ID, секреты и прочее, то все это можно и нужно хранить в месте специально для этого предназначенном. В Azure Automation это Secure Assets. Более подробную информацию можно получить по ссылке.

Ссылка на репозиторий с кодом

Ссылка на канал с запущенным ботом

P.S. буду рад контрибьюторам и предложениям по улучшению бота.