Google Apps ScriptとMicrosoft Graph APIの連携 - Teamsログ取得編【GAS】

社内外でTeamsのチームに投稿されたチャットログを集計したい要望が非常に高くなっています。しかし、このデータを取得するのは容易ではありません。Power Automateだと新規メッセージ投稿時か?自分自身にメンションがあった場合のデータを取ることは出来ますが、それ以外のケースは取れません。また、いいね等の集計も出来ません。誰かが返信やメッセージ投稿したら全て取得するアクションがあれば良いのですが、現在は用意されていません。

そこで使う事になるのがMicrosoft Graph API。ただし管理者権限が必要になります。今回はこのAPIを使って、Google Apps ScriptからTeamsのチャットログを取得してみたいと思います。

※最近Google Apps ScriptはAPI追加とかほとんど止まってるからなぁ。KeepやMeetのAPI用意してくれたらいいのに。

今回使用するスプレッドシート等

事前にAzure ADにてプロジェクトの作成が必要になっています。また、アクセス権限として管理者権限が必要となるので、許可を得る必要があります。

事前準備

利用する為にはAzure ADでアプリ登録を行い、Client IDとClient Secretの2つ取得しておく必要があります。微妙に以前とは取得方法が異なっている為、改めてここで2020年6月現在の取得方法を記述しておきたいと思います。現在は、Azure ADも無償で利用が可能になっているので、フリーアカウントの場合でも構築する事が可能になっています(ただしフリーアカウントで認証を実行できるようにするには、ちょっと手順が必要です。)。この為だけにMicrosoft365契約するのはちょっとね・・・

GAS側の事前準備

ライブラリの追加

以下の手順でOAuth2 for Apps Scriptライブラリを追加しましょう。

  1. スクリプトエディタを開きます。
  2. メニューより「リソース」⇒「ライブラリ」を開きます。
  3. ライブラリを追加欄に「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF」を追加します。
  4. 今回はバージョンは38を選択してみます。
  5. 保存ボタンを押して完了

これで、OAuth2.0認証にまつわる様々な関数を手軽に利用できるようになります。

図:ライブラリを追加した様子

コールバックURLを取得する

コールバックURLとは、認証を完了しAccess Tokenを取得したら戻るべきURLを指定するものです。これは、スクリプトIDをもとに作られているので、スクリプトIDを取得して組み立てます。

  1. スクリプトエディタのメニューより「ファイル」⇒「プロジェクトのプロパティ」を開く
  2. 情報の中にある「スクリプトID」を控えておく。
  3. https://script.google.com/macros/d/スクリプトID/usercallback として組み立てる。これがコールバックURLとなる。

図:スクリプトIDはファイル毎に異なるのです。

Azureでプロジェクトを作成

  1. アプリの登録にて登録を開始する
  2. 新規登録をクリックする
  3. 名前を入力(今回はteamsscraperと入力しました)、リダイレクトURIは「webを選択しhttps://script.google.com/macros/d/スクリプトID/usercallback」を入力
  4. 登録ボタンをクリックする
  5. 出てきた中で、「アプリケーション(クラと書かれているのがクライアントID」なので、このコードをメモしておく
  6. 左サイドバーより、「証明書とシークレット」をクリック
  7. 新しいクライアントシークレット」をクリックする
  8. 今回は特に有効期限を設けないで追加をクリック
  9. これで値に「クライアントシークレット」が生成されて手に入りました。このシークレットはこの時だけしか表示されないので、注意してください。
  10. つづけて、左サイドバーより「APIのアクセス許可」をクリックする
  11. Microsoft APIの中にある「Microsoft Graph」をクリックする。
  12. 委任されたアクセス許可」をクリックする
  13. デフォルトでUser.ReadがすでにONなので、今回はoffline_accessGroup.Read.AllUser.Read.AllGroup.ReadWrite.All、ChannelMessage.Read.Allを検索してONにしましょう。
  14. アクセス許可の追加をクリックする
  15. 追加出来たら、xxxxxに管理者の同意を与えますをクリックします。すると、状態が緑色になります。
  16. 次に左サイドバーより「認証」をクリック
  17. 暗黙の付与にて、「アクセストークン」にチェックを入れる
  18. サポートされているアカウントの種類に於いては、「マルチテナント」にしておきました。
  19. 保存をクリック
  20. 概要のエンドポイントをクリックすると、いろいろなエンドポイントURLが出る。
  21. 概要のディレクトリ(テナントの数値はメモっておきます。あとでプログラム中で使用します。
  22. デフォルトでは組織アカウントでなければOAuth2.0認証が出来ません。フリーのMicrosoftアカウントでも認証できるようにするためには以下の手順が必要です。
  23. 左サイドバーのマニフェストをクリックします。
  24. マニフェストエディタが起動します。その中にあるsignInAudienceの値を「AzureADandPersonalMicrosoftAccount」へ変更して保存をクリック。これで例えばhotmailアカウントでも認証が通るようになります。

※3.でWebを選ばないSPAを選んでしまうと、Proof Key for Code Exchange by OAuth Public Clientsといったエラーが出てしまい認証ができませんので注意。

※個人アカウントでうまく動作しないなぁと思った場合には、Azure Portalのユーザ画面にてonmicrosoft.comのアカウントを作成してそれで認証作業を行うと良い。この時、グループとロールにおいては「アプリケーション管理者、クラウド アプリケーション管理者」の2つが割り当てられてればOKです。個人的にはこの作業をオススメします。以降、ログイン認証等もこのアカウントで行います。

図:アプリの登録から全ては始まります。

図:Graphを選択する

図:アクセス権限付与した状態

図:認証の設定変更に注意

図:フリーアカウントでも可にするとこういう表示になる

図:onmicrosoft.comアカウントのロール

Teamsのログ取得に必要な情報を集める

Teamsのログ取得に必要な情報を集めなければなりません。集めた情報でGraph Explorerを使い実際に取得できるのかどうかをテストする事が可能です。

  1. Graph Explorerに行き、ログインしておく
  2. サンプルクエリにてteamsで検索。チャネル内のメッセージをクリックする
  3. 取得するURLは、https://graph.microsoft.com/beta/teams/{group-id-for-teams}/channels/{channel-id}/messages/{message-id}といったようなスタイル
  4. グループIDがチームのIDとなります。チームを開きこのチームへのリンクを取得でリンクをまず取得します。
  5. groupIdが入ってるので、そのIDを取得します。
  6. 次にチャネルIDが必要です。対象のチャネルを開きます。
  7. URLを見てみると、threadId以下に数字2桁:半角英数文字列~@thread.skypeの文字があります。これがチャネルIDとなるので控えておきます。
  8. チームのIDをgroup-id-for-teamsに入れ、チャネルIDをchannel-idに入れる。message-idは不要なので削除する。
  9. 組み立てたURLを入れてクエリ実行をする
  10. まだこの段階では取れていません。ここでアクセス許可の修正をクリック
  11. 出てきたアクセス一覧の全ての同意をクリックする。この時、onmicrosoft.comアカウントではない場合、ChannelMessage.Read.Allの同意が出来ない事があります。
  12. 無事に取得できると、OK - 200が返ってきて対象のチャネル内の全メッセージが取得出来ます。
  13. ちなみに、なんとなくわかると思いますが、JSONの中のbodyがメッセージ本文、reactionsの中のreactionTypeがいいねに該当します。displayNameが投稿した人の名前ですね。

図:求められたアクセス許可

図:認証が通ると全メッセージがJSONで取得される

認証を行う処理を作成する

OAuth2 for Apps Scriptのページの「Create the OAuth2 Service」にあるコードを元に、Google Apps Script側で構築をします。この時、Microsoft365側で取得したアプリケーションIDやシークレットを使います。また、今回はいつもよりも要求するアクセス権限が多い点と、利用するAPIはBetaを利用する点に注意が必要です(まだ現在、Graph APIでのTeamsログ取得はv1.0では取得出来ません)

GAS側コード

//メニューを構築する
function onOpen(e) {
  var ui = SpreadsheetApp.getUi();
  ui.createMenu('▶OAuth認証')
      .addItem('認証の実行', 'startoauth')
      .addSeparator()
      .addItem('ログアウト', 'reset')
      .addItem('Teamsデータ取得', 'getTeamsLogs')
      .addToUi();
}

//認証用の各種変数
var appid = 'ここにアプリケーションIDを入れる';
var appsecret='ここにクライアントシークレットを入れる';
var scope = "User.Read offline_access Group.Read.All User.Read.All Group.ReadWrite.All ChannelMessage.Read.All"
var endpoint = "https://graph.microsoft.com/beta"
var tokenurl = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
var authurl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"

function startoauth(){
  //UIを取得する
  var ui = SpreadsheetApp.getUi();
  
  //認証済みかチェックする
  var service = checkOAuth();
  if (!service.hasAccess()) {
    //認証画面を出力
    var output = HtmlService.createHtmlOutputFromFile('template').setHeight(450).setWidth(500).setSandboxMode(HtmlService.SandboxMode.IFRAME);
    ui.showModalDialog(output, 'OAuth2.0認証');
  } else {
    //認証済みなので終了する
    ui.alert("すでに認証済みです。");
  }
}

//アクセストークンURLを含んだHTMLを返す関数
function authpage(){
  var service = checkOAuth();
  var authorizationUrl = service.getAuthorizationUrl();
  var html = "<center><b><a href='" + authorizationUrl + "' target='_blank' onclick='closeMe();'>アクセス承認</a></b></center>"
  return html;
}

//認証チェック
function checkOAuth() {
  return OAuth2.createService("Microsoft Graph")
    .setAuthorizationBaseUrl(authurl)
    .setTokenUrl(tokenurl)
    .setClientId(appid)
    .setClientSecret(appsecret)
    .setScope(scope)
    .setCallbackFunction("authCallback") //認証を受けたら受け取る関数を指定する
    .setPropertyStore(PropertiesService.getScriptProperties())  //スクリプトプロパティに保存する
    .setParam("response_type", "code");
}

//認証コールバック
function authCallback(request) {
  var service = checkOAuth();
  Logger.log(request);
  var isAuthorized = service.handleCallback(request);
  if (isAuthorized) {
    return HtmlService.createHtmlOutput("認証に成功しました。ページを閉じてください。");
  } else {
    return HtmlService.createHtmlOutput("認証に失敗しました。");
  }
}

//ログアウト
function reset() {
  checkOAuth().reset();
  SpreadsheetApp.getUi().alert("ログアウトしました。")
}
  • 今回利用するGraph APIのエンドポイントはhttps://graph.microsoft.com/betaとなります。
  • 要求する権限はscopeに半角スペースで区切って、Azure AD側で用意したものと同じものを設定します。
  • startoauthを実行して認証を実行すれば、スクリプトプロパティにAccess Tokenが格納されます。

HTML側コード

template.htmlというダイアログ用のファイルを用意します。ここでアクセス承認を実行し、ログインをすると、アクセストークンその他が取得可能になります。

<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<script type="text/javascript" src="https://apis.google.com/js/api.js"></script>
<script>
  google.script.run.withSuccessHandler(onSuccess).authpage();
  
  function onSuccess(data){
    document.getElementById("kinoko").innerHTML = data;
  }

</script>

<style type="text/css">
/* --- ボックス --- */
div.section {
width: 480px; /* ボックスの幅 */
background-color: #ffffff; /* ボックスの背景色 */
border: 1px #c0c0c0 solid; /* ボックスの境界線 */
font-size: 100%; /* ボックスの文字サイズ */
}

/* --- 見出し --- */
div.section h3 {
margin: 0; /* 見出しのマージン */
padding: 6px 10px; /* 見出しのパディング(上下、左右) */
background-color: #f5f5f5; /* 見出しの背景色 */
border-bottom: 1px #c0c0c0 solid; /* 見出しの下境界線 */
font-size: 120%; /* 見出しの文字サイズ */
}

/* --- ボックス内の段落 --- */
div.section p {
margin: 1em 10px; /* 段落のマージン(上下、左右) */
}
</style>

<div class='section'>
<img border="0" src="https://officeforest.org/library/oauth2.png" alt="oauth2">
<h3 id='header'>OAuth認証の許可が必要です。</h3>
<hr>
<div id="info">
<p>
このスクリプトは、Microsoft Graph APIにアクセスするために、特別なログイン処理を利用しています。<br>
既に特別なログインに関する設定はなされており、承認がされるとプログラムを実行することが出来ます。この承認がなされない場合、プログラムの実行に制限が掛かり、
処理が続行できません。<br><br>

<div id="kinoko"></div>
</div>
<p>
<script>
function closeMe(){
  if(google && google.script && google.script.host){
      google.script.host.close();
  } else if(window && window.close){
      window.close();
  } 
}
</script>
</div>
  • 実際にこれらのコードで、startoauthを実行すると、スプレッドシート上で認証用のダイアログが出ます。
  • 認証でMicrosoft365アカウントにログインします(もしくは作成したonmicrosoft.comのアカウント
  • 取得したAccess Tokenほかはスクリプトプロパティのoauth2.Graphという項目にガッツリ値が格納されます。ここにはAccess Token, Refresh Token, expire_inのタイムなどが入っています。
  • reset関数はログアウトされて、再度認証ができるようになります。
  • Chrome v83.xを利用している場合、認証実行時にリダイレクトURLにジャンプ出来ずにエラーになることがあります。認証用URLの中のredirect_uriの文字がオカシナ文字に置き換わっていてredirect_uriが違うと怒られるケースがあります。その場合はURLを直接リダイレクトURIの部分を書き換えてください。

図:無事に認証画面へ到達出来た

図:スクリプトプロパティにAccess Tokenが格納された

取得したTeamsログをスプレッドシートに書き出す処理

大雑把ではありますが、GAS側から必要最低限の対象のチャネルのチャットログをすべて取得してみたいと思います。実用するには以下のような装備も別途必要ではないかと思います。またスレッドに対する返信は別のAPIを呼び出して返信を取得させる必要があるので、返信レスは親スレッドの数だけAPIを叩く必要性があります。

  1. idを使って取得済みのエントリーについてはスルーする仕組み
  2. 今回取得対象にしていない「添付ファイル」などについても必要であれば、Google Driveにコピーするような処理
  3. 取得対象にするチャネルを固定ではなく、選択できるように動的に指定する仕組み(これもGraph APIで一覧が取得可能です)
  4. 取得したデータを元に分析・レポート自動作成機能など
  5. 他のAPIを利用してデータを特定のアドレスに対して送り込む機能
  6. 他のAPIを利用してTeamsにチャットを送信する機能など(ただしこちらは、API使わずともIncoming Webhookでも代用できるけれど)
  7. 日付でフィルタするような仕組みも必要かもしれません。
  8. 今回はいいねは数だけを集計していますが、リアクションの種類で集計を変えるような手法も面白いかもしれません。
//Teams情報
var groupId = "ここにグループIDを入れる";
var channelId = "ここにチャネルIDを入れる";

//Teamsのチャネルからメッセージを抜き出す
function getTeamsLogs() {
  //書き込み先シートを取得する
  var ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Teamslog")
  var ui = SpreadsheetApp.getUi();
  
  //Graph APIサービスを取得する
  var service = checkOAuth();
  if (service.hasAccess()) {
    //Teamsのログを取得する
    var teamsurl = endpoint + "/teams/" + groupId + "/channels/" + channelId + "/messages/"
    var teams = graphTeamsGet("GET", teamsurl);
    
    //スレッドの件数を取得
    var loglength = teams.value.length;
    
    //スレッドを回して各種データを配列に突っ込む
    for(var i = 0;i<loglength;i++){
      //一時配列を用意する
      var array = [];
      
      //レスデータの塊を取得する
      var templog = teams.value[i];
      
      //IDを取得する
      var msgid = templog.id;
      
      //親メッセージを取得する
      var parentmsg = templog.body.content
      
      //送信者を取得する
      var parentusr = templog.from.user.displayName
      
      //日時を取得する
      var createdate = templog.createdDateTime
      
      //親スレッドへのリアクションがあれば取得
      var parrectlen = templog.reactions.length
      
      //親スレッドをまずappendrowする
      array.push(msgid);
      array.push(new Date(createdate));
      array.push(parentusr);
      array.push("");
      array.push(parentmsg);
      array.push(parrectlen);
      ss.appendRow(array);
      
      //親スレッドにレスがあるかどうか確認してレコードを追加する
      var teamschild = teamsurl + msgid + "/replies/"
      var resteams = graphTeamsGet("GET", teamschild);
      
      //レス数を取得する
      var rescnt = resteams.value.length;
      
      //0件の場合はスルーする
      if(rescnt == 0){
        //レス無しなのでスルーする
      }else{
        //レスデータをレコードに追加していく
        for(var j = 0;j<rescnt;j++){
          //一時配列を用意する
          var resarray = [];
        
          //レスデータの塊を取得する
          var reslog = resteams.value[j];
          
          //IDを取得する
          var resid = reslog.id;
          
          //返信メッセージを取得する
          var resmsg = reslog.body.content
          
          //送信者を取得する
          var resfrom = reslog.from.user.displayName
          
          //日時を取得する
          var resdate = reslog.createdDateTime
          
          //リアクションがあれば取得
          var resreact = reslog.reactions.length
        
          //レスデータをappendrow
          resarray.push(resid);
          resarray.push(new Date(resdate));
          resarray.push(resfrom);
          resarray.push(msgid);
          resarray.push(resmsg);
          resarray.push(resreact);
          ss.appendRow(resarray);
        }
      }
    }

    //終了処理
    ui.alert("データの取得に成功しました。")
    
  } else {
    ui.alert("認証が実行されていませんよ。");
  }
}

//メッセージスレッドを取得する関数
function graphTeamsGet(method, eUrl) {
  //Graph APIサービスを取得する
  var service = checkOAuth();

  if (service.hasAccess()) {
    //HTTP通信
    var response = UrlFetchApp.fetch(eUrl, {
      headers: {
        Authorization: "Bearer " + service.getAccessToken()
      },
      method: method,
      contentType: "application/json"
    });
    
    //取得した値を返す
    return JSON.parse(response.getContentText());
  }else{
    //エラーを返す(認証が実行されていない場合)
    return "error";
  }
}
  • teamsurlをまず叩いて、親スレッドの一覧を取得します。親スレッドの項目をレコードとして追加しましょう。
  • 親スレッドのidを元に次に返信レスがあるかteamschildを叩いて、返信レスの一覧を取得します。0件の場合には処理をせずに次の処理へ移動します。
  • 処理すべき返信があったら、返信先に親スレッドのidを入れて、親スレッドの時と同様にJSONを分解してレコードを追加します。
  • いいねの数reactionsのlengthで取得させています。
  • その他レスした日付、メッセージ本文、送信者名を取得してappendRowでレコードを追加しています。
  • データの取得はgraphTeamsGet関数がすべて担当しています。ここでAccess Tokenを使ってGraph APIを叩き、Teamsのログを取得しています。
  • スレッドの数やレス数が膨大な場合、UrlfetchAppのQuota制限GASの実行時間制限(Basicだと6分)に引っかかる可能性があります。回避する為にsleepを入れたり、うまいこと制限を回避するような仕組みも必要かもしれません。
  • Node.js + Passport Azure AD + Electronでアプリケーションを組んだほうが現実的かもしれません(こちらなら制限が掛かりにくい)。
  • ちなみに、データは投稿された日付が新しいほうから取得されていくので、最新版のレスが一番上になります。

図:親スレッドと返信データをすべて抽出できました

Teamsのタブに埋め込む

Google Apps Scriptで作ったウェブアプリケーションやスプレッドシートは、Teamsのタブに埋め込む事が可能です。作業はひどく簡単なので、こうしておくことで、実際にそのTeams関連のアプリをダイレクトにその場で作業ができるのもメリットの一つです。

  1. Google SpreadsheetやGoogle Apps Scriptで作成したウェブアプリケーションのURLを取得する
  2. Teamsのチャネル上部にある+ボタンをクリック
  3. タブの追加では「Webサイト」をクリック
  4. タブ名を入れて、1.のURLを入れて、保存ボタンをクリック
  5. これでチャネルにスプレッドシートやウェブアプリが埋め込み可能です。流れ作業でそのまま関連の仕事をできるので非常に効率アップします。

図:スプレッドシートにアプリを埋め込み

返ってくるJSONのサンプル

今回のTeamsデータ取得にてGraph APIから返ってくるJSONのサンプルは以下のような感じになります。valueにJSONの塊としてレスが複数入ってくるイメージです。

{
    @odata.count=取得したメッセージ件数, 
    @odata.context=リクエスト先のTeamsチャネルのURL,
    value=[
        {
            summary=null, 
            lastModifiedDateTime=null, 
            attachments=[], 
            replyToId=null, 
            subject=メッセージのタイトルがここに入ります, 
            importance=normal, 
            createdDateTime=2019-08-23T01:48:06.073Z, 
            deletedDateTime=null, 
            policyViolation=null, 
            locale=en-us, 
            body={
                contentType=html, 
                content=ここにメッセージ本文が入ります。HTMLの場合はHTMLタグで
            }, 
            messageType=message, 
            webUrl=対象のレスの直リンクURLがここに入ります, 
            mentions=[
                {
                    mentionText=メンション先のdisplayNameがここに入ります。, 
                    id=0, 
                    mentioned={
                        application=null, 
                        device=null, 
                        ser={
                            displayName=メンション先のdisplayNameがここに入ります。, 
                            id=23359f59-3ebc-4553-88b8-xxxxxxxx, 
                            userIdentityType=aadUser
                        }, conversation=null
                    }
                }
            ], 
            etag=1566525194151, 
            from={
                application=null, 
                device=null, 
                user={
                    displayName=レスした人のdisplayNameがここに入ります。, 
                    id=3f52f2a7-24f0-48fe-a940-xxxxxxxx, 
                    userIdentityType=aadUser
                }, 
                conversation=null
            }, 
            reactions=[
                {
                    reactionType=いいねのリアクションタイプがここに入ります, 
                    createdDateTime=2019-08-23T01:53:14.151Z, 
                    user={
                        application=null, 
                        device=null, 
                        user={
                            displayName=null, 
                            id=3f52f2a7-24f0-48fe-a940-xxxxxxx7, 
                            userIdentityType=aadUser
                        }, conversation=null
                    }
                }
            ], 
            id=レスのユニークなIDがここに入ります
        }
    ]
}

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)