[VSTS][Teams]讓 VSTS 的發佈通知可以跟 Free 版本的 Teams 進行整合

透過搭配 Azure Queue + Azure Function,讓原本無法直接讓 VSTS 和 Free 版本的 Teams 整合的部分,可以順利打通

這幾天試著要導入 Teams 來取代原本所使用的 Slack,原本想說這應該是個簡單任務,沒有想到卻嚴重吃鱉。因為我們所使用的是 Free 版本的 Teams,因此他所能使用的連接器,是沒有 VSTS 可以選擇的,因此我們只能使用「傳入 Webhook」的連接器來做整合了

但那樣看起來是高興的太早一點,當我到 VSTS 下面,去選擇新增一個 Service Hook 的時候,此時會發現當我們選擇 Microsoft Teams 的時候,會出現警告訊息不讓我們使用。

而既然前面的步驟在 Teams 上我們是選擇 WebHook 的方式來連接,那這裡我們應該也可以來試試直接使用 「Web Hooks」 的方式來連接

因此我們回到 Teams 先把頻道的的連接器上,去將該頻道的傳送網址,貼到 VSTS 上針對 Web Hook 所使用的網址

但當我們按下測試的時候,卻發現是失敗的( 下圖 1 的錯誤訊息 ),因此我們選擇上面的 Requests 來做查看,看是否有遺漏的設定。

而從 Request 中可以看出來,看起來不是沒有傳送成功,而是 VSTS 所傳出來的 Web Hooks 的資料,跟 Teams 所要的格式並不相同,因此才會導致錯誤。


透過前面那些程序,我們可以知道基本上沒有 VSTS 的專用連接器,透過 VSTS 直接傳送訊息是沒有辦法串接起來的,因此看起來在這裡我們要做個處理,將 VSTS 傳送過來的訊息,擷取部分需要通知人員的部分,然後重新發送到 Teams 的環境。而為了方便好維護,因此在這裡我們會採用 Azure Queue & Azure Function 來聯合,以最便宜的方式來完成這個貍貓換太子的工程了。

因此首先我先在 Azure 上面建立一個 Storage ,取得 Access key 之後,也順便建立一個 Queue 來放等一下我們要傳送的訊息

因此這裡我們建立一個名稱為 demo 的 queue

接著我們回到 VSTS 上,將訊息指定傳送到這個 queue 裡面來排隊進行處理,可以在我們的專案下選擇 Project settingsService hooks ,然後再新增的時候我們如下圖指定到 Azure Storage ,接著我們就可以選擇 Next

接著選擇在甚麼狀況下要傳送,這裡可以按照您自己的狀況來選擇,我先用預設值,將所有 Build 的資訊會送到 Queue 

接下來就是最重要的下圖這三個參數,分別填入 Storage 的名稱 ,Access Key 和 Queue 名稱,這三個就按照一開始建立 Queue 時候所取得的給填入即可

要是設定沒有問題,接著就可以按下下方 Test 的按鈕來進行測試,驗證相關參數是否有設定錯誤

而回到 Azure 上面的 Queue 的設定中,我們也可以看到有一筆紀錄了,這筆紀錄就是我們後續要拿來傳到 Teams 中的資訊。

接下來我們就可以準備寫 Azure Function 了,但在寫之前,因為傳到 Queue 的 JSON 後續我們在 Azure Function 要將她轉換為 Object 的方式來操作,因此這裡我們偷懶一下,將剛才 Queue 裡面的資料複製出來

{
	"id": "d6ac459c-18b3-44ff-95b5-b5f03db672ea",
	"eventType": "build.complete",
	"publisherId": "tfs",
	"message": {
		"text": "Build 20150407.2 succeeded",
		"html": "Build <a href=\"https://fabrikam-fiber-inc.visualstudio.com/web/build.aspx?pcguid=5023c10b-bef3-41c3-bf53-686c4e34ee9e&amp;builduri=vstfs%3a%2f%2f%2fBuild%2fBuild%2f4\">20150407.2</a> succeeded",
		"markdown": "Build [20150407.2](https://fabrikam-fiber-inc.visualstudio.com/web/build.aspx?pcguid=5023c10b-bef3-41c3-bf53-686c4e34ee9e&builduri=vstfs%3a%2f%2f%2fBuild%2fBuild%2f4) succeeded"
	},
	"detailedMessage": {
		"text": "Build 20150407.2 succeeded",
		"html": "Build <a href=\"https://fabrikam-fiber-inc.visualstudio.com/web/build.aspx?pcguid=5023c10b-bef3-41c3-bf53-686c4e34ee9e&amp;builduri=vstfs%3a%2f%2f%2fBuild%2fBuild%2f4\">20150407.2</a> succeeded",
		"markdown": "Build [20150407.2](https://fabrikam-fiber-inc.visualstudio.com/web/build.aspx?pcguid=5023c10b-bef3-41c3-bf53-686c4e34ee9e&builduri=vstfs%3a%2f%2f%2fBuild%2fBuild%2f4) succeeded"
	},
	"resource": {
		"id": 1,
		"status": "completed",
		"result": "succeeded",
		"queueTime": "2015-04-07T17:22:56.22Z",
		"startTime": "2015-04-07T17:23:02.4977418Z",
		"finishTime": "2015-04-07T17:24:20.763574Z",
		"url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/71777fbc-1cf2-4bd1-9540-128c1c71f766/_apis/build/Builds/1",
		"definition": {
			"id": 1,
			"name": "CustomerAddressModule",
			"url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/71777fbc-1cf2-4bd1-9540-128c1c71f766/_apis/build/Definitions/1",
			"type": "build",
			"queueStatus": "enabled",
			"revision": 2,
			"project": {
				"id": "71777fbc-1cf2-4bd1-9540-128c1c71f766",
				"name": "Git",
				"url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/projects/71777fbc-1cf2-4bd1-9540-128c1c71f766",
				"state": "wellFormed",
				"visibility": "unchanged"
			}
		},
		"uri": "vstfs:///Build/Build/1",
		"sourceBranch": "refs/heads/master",
		"sourceVersion": "600C52D2D5B655CAA111ABFD863E5A9BD304BB0E",
		"queue": {
			"id": 1,
			"name": "default",
			"pool": null
		},
		"priority": "normal",
		"reason": "batchedCI",
		"requestedFor": {
			"displayName": "Normal Paulk",
			"url": "https://fabrikam-fiber-inc.visualstudio.com/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db",
			"id": "d6245f20-2af8-44f4-9451-8107cb2767db",
			"uniqueName": "fabrikamfiber16@hotmail.com",
			"imageUrl": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db"
		},
		"requestedBy": {
			"displayName": "[DefaultCollection]\\Project Collection Service Accounts",
			"url": "https://fabrikam-fiber-inc.visualstudio.com/_apis/Identities/b873e41d-7ebf-4e56-a3ce-ec582975baf6",
			"id": "b873e41d-7ebf-4e56-a3ce-ec582975baf6",
			"uniqueName": "vstfs:///Framework/Generic/5023c10b-bef3-41c3-bf53-686c4e34ee9e\\Project Collection Service Accounts",
			"imageUrl": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_api/_common/identityImage?id=b873e41d-7ebf-4e56-a3ce-ec582975baf6",
			"isContainer": true
		},
		"lastChangedDate": "2015-04-07T17:24:20.883Z",
		"lastChangedBy": {
			"displayName": "[DefaultCollection]\\Project Collection Service Accounts",
			"url": "https://fabrikam-fiber-inc.visualstudio.com/_apis/Identities/b873e41d-7ebf-4e56-a3ce-ec582975baf6",
			"id": "b873e41d-7ebf-4e56-a3ce-ec582975baf6",
			"uniqueName": "vstfs:///Framework/Generic/5023c10b-bef3-41c3-bf53-686c4e34ee9e\\Project Collection Service Accounts",
			"imageUrl": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_api/_common/identityImage?id=b873e41d-7ebf-4e56-a3ce-ec582975baf6",
			"isContainer": true
		},
		"orchestrationPlan": {
			"planId": "b67fddb8-8036-47cf-a472-61aa7d9b53e8"
		},
		"logs": {
			"id": 0,
			"type": "Container",
			"url": "https://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/71777fbc-1cf2-4bd1-9540-128c1c71f766/_apis/build/builds/1/logs"
		},
		"repository": {
			"id": "47de4095-dda2-4d32-a8df-9812ae598179",
			"type": "TfsGit",
			"clean": null,
			"checkoutSubmodules": false
		},
		"triggeredByBuild": null
	},
	"resourceVersion": "2.0-preview.2",
	"resourceContainers": {
		"collection": {
			"id": "c12d0eb8-e382-443b-9f9c-c52cba5014c2"
		},
		"account": {
			"id": "f844ec47-a9db-4511-8281-8b63f4eaf94e"
		},
		"project": {
			"id": "be9b3917-87e6-42a4-a549-2bc06a7a878f"
		}
	},
	"createdDate": "2018-08-12T04:02:25.6812472Z"
}

 

接著我們透過 http://json2csharp.com/ 這個網站,將我們的 JSON 轉成 C# Class 的型態,這樣後續我們再寫 Azure Function 就會容易多了

public class Message
{
    public string text { get; set; }
    public string html { get; set; }
    public string markdown { get; set; }
}

public class DetailedMessage
{
    public string text { get; set; }
    public string html { get; set; }
    public string markdown { get; set; }
}

public class Project
{
    public string id { get; set; }
    public string name { get; set; }
    public string url { get; set; }
    public string state { get; set; }
    public string visibility { get; set; }
}

public class Definition
{
    public int id { get; set; }
    public string name { get; set; }
    public string url { get; set; }
    public string type { get; set; }
    public string queueStatus { get; set; }
    public int revision { get; set; }
    public Project project { get; set; }
}

public class Queue
{
    public int id { get; set; }
    public string name { get; set; }
    public object pool { get; set; }
}

public class RequestedFor
{
    public string displayName { get; set; }
    public string url { get; set; }
    public string id { get; set; }
    public string uniqueName { get; set; }
    public string imageUrl { get; set; }
}

public class RequestedBy
{
    public string displayName { get; set; }
    public string url { get; set; }
    public string id { get; set; }
    public string uniqueName { get; set; }
    public string imageUrl { get; set; }
    public bool isContainer { get; set; }
}

public class LastChangedBy
{
    public string displayName { get; set; }
    public string url { get; set; }
    public string id { get; set; }
    public string uniqueName { get; set; }
    public string imageUrl { get; set; }
    public bool isContainer { get; set; }
}

public class OrchestrationPlan
{
    public string planId { get; set; }
}

public class Logs
{
    public int id { get; set; }
    public string type { get; set; }
    public string url { get; set; }
}

public class Repository
{
    public string id { get; set; }
    public string type { get; set; }
    public object clean { get; set; }
    public bool checkoutSubmodules { get; set; }
}

public class Resource
{
    public int id { get; set; }
    public string status { get; set; }
    public string result { get; set; }
    public DateTime queueTime { get; set; }
    public DateTime startTime { get; set; }
    public DateTime finishTime { get; set; }
    public string url { get; set; }
    public Definition definition { get; set; }
    public string uri { get; set; }
    public string sourceBranch { get; set; }
    public string sourceVersion { get; set; }
    public Queue queue { get; set; }
    public string priority { get; set; }
    public string reason { get; set; }
    public RequestedFor requestedFor { get; set; }
    public RequestedBy requestedBy { get; set; }
    public DateTime lastChangedDate { get; set; }
    public LastChangedBy lastChangedBy { get; set; }
    public OrchestrationPlan orchestrationPlan { get; set; }
    public Logs logs { get; set; }
    public Repository repository { get; set; }
    public object triggeredByBuild { get; set; }
}

public class Collection
{
    public string id { get; set; }
}

public class Account
{
    public string id { get; set; }
}

public class Project2
{
    public string id { get; set; }
}

public class ResourceContainers
{
    public Collection collection { get; set; }
    public Account account { get; set; }
    public Project2 project { get; set; }
}

public class RootObject
{
    public string id { get; set; }
    public string eventType { get; set; }
    public string publisherId { get; set; }
    public Message message { get; set; }
    public DetailedMessage detailedMessage { get; set; }
    public Resource resource { get; set; }
    public string resourceVersion { get; set; }
    public ResourceContainers resourceContainers { get; set; }
    public DateTime createdDate { get; set; }
}

 

完成前置預備後,我們就可以到 Azure Portal 上去建立 Azure Function 了,基本上建立在那裡或存放在甚麼地區,就按照您的習慣去選擇就可以了。

接著我們開始建立一個我們這次的主角 Azure Function ,這裡我們選擇 Queue Trugger 的 C# 範本來坐實作

選好範本之後,會出現相關設定,Function 名稱那些不大重要,最重要是下方那個有關於 Queue 的設定,要是沒有設定對就無法啟動,因此ㄧ開始我們選擇 new 去指定連接到我們存放 Queue 的 Storage 上,這一步設定好就沒有大問題了。

基本上程式可以參考以下的內容,其實真正寫的沒有幾行,主要是把 Queue 的資料轉成物件,然後再用 Http Client 去呼叫 Teams

#r "Newtonsoft.Json"

using System;
using System.Net.Http.Headers;
using Newtonsoft.Json;


public class Message
{
    public string text { get; set; }
    public string html { get; set; }
    public string markdown { get; set; }
}

public class DetailedMessage
{
    public string text { get; set; }
    public string html { get; set; }
    public string markdown { get; set; }
}

public class Drop
{
    public string location { get; set; }
    public string type { get; set; }
    public string url { get; set; }
    public string downloadUrl { get; set; }
}

public class Log
{
    public string type { get; set; }
    public string url { get; set; }
    public string downloadUrl { get; set; }
}

public class LastChangedBy
{
    public string displayName { get; set; }
    public string url { get; set; }
    public string id { get; set; }
    public string uniqueName { get; set; }
    public string imageUrl { get; set; }
}

public class Definition
{
    public int batchSize { get; set; }
    public string triggerType { get; set; }
    public string definitionType { get; set; }
    public int id { get; set; }
    public string name { get; set; }
    public string url { get; set; }
}

public class Queue
{
    public string queueType { get; set; }
    public int id { get; set; }
    public string name { get; set; }
    public string url { get; set; }
}

public class RequestedFor
{
    public string displayName { get; set; }
    public string url { get; set; }
    public string id { get; set; }
    public string uniqueName { get; set; }
    public string imageUrl { get; set; }
}

public class Request
{
    public int id { get; set; }
    public string url { get; set; }
    public RequestedFor requestedFor { get; set; }
}

public class Resource
{
    public string uri { get; set; }
    public int id { get; set; }
    public string buildNumber { get; set; }
    public string url { get; set; }
    public DateTime startTime { get; set; }
    public DateTime finishTime { get; set; }
    public string reason { get; set; }
    public string status { get; set; }
    public string dropLocation { get; set; }
    public Drop drop { get; set; }
    public Log log { get; set; }
    public string sourceGetVersion { get; set; }
    public LastChangedBy lastChangedBy { get; set; }
    public bool retainIndefinitely { get; set; }
    public bool hasDiagnostics { get; set; }
    public Definition definition { get; set; }
    public Queue queue { get; set; }
    public List<Request> requests { get; set; }
}

public class Collection
{
    public string id { get; set; }
}

public class Account
{
    public string id { get; set; }
}

public class Project
{
    public string id { get; set; }
}

public class ResourceContainers
{
    public Collection collection { get; set; }
    public Account account { get; set; }
    public Project project { get; set; }
}

public class RootObject
{
    public string id { get; set; }
    public string eventType { get; set; }
    public string publisherId { get; set; }
    public Message message { get; set; }
    public DetailedMessage detailedMessage { get; set; }
    public Resource resource { get; set; }
    public string resourceVersion { get; set; }
    public ResourceContainers resourceContainers { get; set; }
    public DateTime createdDate { get; set; }
}

public class TeamsHook
{
    public string title {get;set;}
    public string themeColor{get;set;}
    public string text {get;set;}
}


public static void Run(string myQueueItem, TraceWriter log)
{
    if (!String.IsNullOrEmpty(myQueueItem)) 
    {
        RootObject _RootObject = JsonConvert.DeserializeObject<RootObject>(myQueueItem);

        //Push message through Teams
        using (var client = new HttpClient()) 
        { 
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            client.DefaultRequestHeaders.Add("User-Agent", "AzureFunctions");
        
            //Copy paste uri from Teams
            var uri = "https://outlook.office.com/webhook/d4cab896-270d-9f056df4e9d7";
            var msg = new TeamsHook {title=_RootObject.message.html,themeColor="0072C6"};
            msg.text = String.Format("- **Request :** {0}\r\n- **Duration :** {1}\r\n- **Define :** {2}",
                        _RootObject.resource.requests[0].requestedFor.displayName,
                        _RootObject.resource.finishTime.Subtract(_RootObject.resource.startTime).ToString(@"hh\:mm\:ss"),
                        _RootObject.resource.definition.name );
        
            StringContent TeamsMsg = new StringContent(JsonConvert.SerializeObject(msg));
            HttpResponseMessage response = client.PostAsync(uri,TeamsMsg).Result; 
            var responseString = response.Content.ReadAsStringAsync().Result;   
        
            log.Info(responseString);
        }
    }
}

 

當我們完成上述設定之後,就可以回到 VSTS 內去測試看看囉。這裡我重新產生一次新的 Build,果然就可以按照我們所想要的,將資料傳到 VSTS 上面了。


後記 : 花了兩天的時間總算搞定,感謝 Teams 大使 家齊的幫忙,提供相關資訊讓我繞出 Web Hook 的死胡同;也謝謝社群的好友 Duran,幫忙弱弱的我把 Timespan 的處理寫好,差點自己在那裏慢慢地去計算。

參考資料 https://edwardkuo.imas.tw/paper/2016/11/27/Azure/O365API/