Google Apps ScriptとMicrosoft Graph APIの連携 – Teams投稿編【GAS】

Slack用にMicrosoftがMicrosoft365に用意してきたチャットツールとして、Microsoft Teamsがあります。Googleで言えばHangout Chatがそれになります。このTeamsにエントリー投稿しかまだ出来ないのですが、Google Apps Scriptから投稿する事が可能です。Graph APIでも可能なのですが、TeamsにはWebookという便利な機能があるので、今回これを使ってみます。

Incoming Webhookへ送りつけるケースは通常のPOSTで送りつけるだけで送信出来ます。より詳細なコントロールをする場合は、Graph APIを使って認証を行った上でAPIリクエストが必要です。今回はこの両方について作成します。

※2018年7月12日より、Microsoft365ユーザ以外も無償版としてTeamsが使えるようになっています。サインアップが必要です。

今回使用するファイル

事前準備

Microsoft Graph API連携の時と同じように、アプリケーションIDとシークレット、スコープを設定する必要があります。また、OAuth2.0認証の為に、ライブラリも追加しましょう。Webhookを使うケースは、Incoming Webhookを準備するだけでOKです。

※今回はチャンネルへの投稿なので、個人宛投稿については、別途Graph APIのchatMessage APIを利用する必要があります。

ライブラリの追加

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

  1. スクリプトエディタを開きます。
  2. メニューより「リソース」⇒「ライブラリ」を開きます。
  3. ライブラリを追加欄に「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF」を追加します。
  4. 現時点ではバージョンは30が最新ですので、それを選択しておきます。
  5. 保存ボタンを押して完了

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

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

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

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

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

対象チャンネルのURLを取得する

Graph APIで叩く場合に必要です。このURLの取得はいたって簡単。

  1. ChromeでTeamsにログインする
  2. Teamsの対象のチャンネルを開く
  3. URLが出てると思うのでこれをコピーしておく
  4. あとで関数でこのURLからgroupIdとChannelIdを取り出すようにしています。

Azureプロジェクトの作成

  1. アプリの登録にて登録を開始する
  2. 新規登録をクリックする
  3. 名前を入力(今回はoutlookmanと入力しました)、リダイレクト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なので、今回はProfile、User.Read、Files.ReadWrite、offline_access、ChannelMessage.Sendを検索して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です。個人的にはこの作業をオススメします。以降、ログイン認証等もこのアカウントで行います。

今回のスコープは管理者承認が不要なものだけで構成されており、実はGroup.ReadWrite.Allの権限がなくても送信だけは行うことが出来ます(Graph Explorer上だとこの権限をアクセス許可で求められますが、ChannelMessage.Sendの承認のみでOK)

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

図:Graphを選択する

図:スコープの設定で許可するアクションを選びます。

Incoming Webhookを追加する

Teams単体でもGraph APIを使って投稿ができるのですが、ID調べたりチャンネルID調べたりが面倒なので、この機能をTeamsに追加し、Webhook URLに対して投稿処理を実行します。以下の手順で追加します。

  1. Teamsの左サイドで「チーム」を選択します。チームを作成していない場合には適当に作成しておいてください。
  2. 作ったチームをクリックすると、デフォルトチャネルとして「一般」というものがありますので、その隣の「」をクリック。
  3. コネクタをクリックします。
  4. いろいろなコネクタが出てくるのですが、ここでは、Incoming Webhookを追加します。
  5. 構成画面が出るので、適当に名前を入れます。これが投稿者の名前になります。
  6. 投稿者の画像を変更する場合にはここで画像をアップロードしておきます。
  7. 作成ボタンを押して準備完了
  8. Webhook URLが下の方に出てくるので、コピーして控えておきます。このあとコード内に記述します。
  9. 完了ボタンを押して終了。

図:Incoming Webhookを追加します。

図:Webhook構成画面

Teamsサービスの制限

Outlookの場合同様に、Teamsにも個別に送信制限が設けられています。主なTeamsの場合の制限は以下の通り。

  • テナント毎に10秒間に15000リクエストまで
  • 一度の要求で最大4つまで同時処理可能
  • 特定のチャネルに対してはアプリ毎に1日3000リクエストまで

また、細かいアクションに対して、「1秒間当たり」の制限が複数設けられています。

  • GETでチャネルメッセージにアクセスは5回/秒
  • POSTでチャネルメッセージにアクセスは2回/秒
  • 1:1のチャットの場合、メッセージ取得は3回/秒

などなど13項目が用意されており、上記は特に厳しいリクエスト制限が加えられているので、ウェイトの値を調整する必要があります。リミットに達してしまった場合にはエラーコードは429が返ってきます。エラーレスポンスのヘッダにあるRetry-Afterの値分だけ待機させる事で再試行が可能です。

※Node.jsで処理を行う場合、注意すべきは2つ目の一度の要求で4つまで同時処理の部分。Node.jsは非同期で処理を行ってしまうので、どんどんリクエストを投げてしまうと引っかかります。Promiseなどで1通ずつリクエストを送るように構築しないと、account throttle limitとエラーが出てしまいます。使用毎、アプリ毎、ユーザ毎にこれはカウントされているので、同じアプリであれば複数名が使っているケースでは、合計で計算されますので要注意。

ソースコード

今回、認証を行う部分のソースコードは準備編を参照してください。Teamsにエントリーを投稿する部分のコードを記載します。

Webhookへ送りつけるケース

こちらのケースはIncoming Webhookを追加しそのURLを叩くだけの簡単な仕様です。OAuth認証などは必要ありません。

HTML側コード(teams.html)

<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<style>
  TEXTAREA{
    width:95%;
    height:200px;
  }
</style>

<script>
  //GAS側にチャット情報を送り込むルーチン
  function disp(){
    //内容を取得する
    var title = document.getElementById("wasabi1").value;
    var chat = document.getElementById("wasabi2").value;
    
    //GAS側へ送り込む
    google.script.run.postTeamsChat(title,chat);
    
    //プログレス表示
    document.getElementById("kinokoman").style.display = "none";
    document.getElementById("sendprogress").style.display = "block";
  }
</script>

<div width="95%" id='kurukuru'>
  <div style='padding:3px 10px;border-color:#0B0099;border-width:0 0 1px 7px;border-style:solid;background:#F8F8F8;'>🍄タイトル</div><p>
  <input type="text" name="name" size="70" maxlength="20" id="wasabi1">

  <div style='padding:3px 10px;border-color:#0B0099;border-width:0 0 1px 7px;border-style:solid;background:#F8F8F8;'>😺チャット内容</div><p>
  <TEXTAREA class='wasabi' placeholder='チャット内容をここに記録します。' id='wasabi2'></TEXTAREA><p>

  <p>
  <div align='center' id='kinokoman' style="display:block;">
    <hr><button class='action' onClick='disp();'>チャット送信</button>
  </div>
  
  <div id="sendprogress" style="display:none;" align='center'>
    <span id="progress">送信中・・・<img border='0' src='https://eye4brain.sakura.ne.jp/wp/library/icons/spinner.gif' width='32' height='32'></span>
  </div>
</div>
  • disp関数でGAS側のpostTeamChat関数へタイトルとチャット内容を送り込んでいます。
  • 送信時に連続送信しないように、プログレス表示に切り替えを追加しています。

図:チャット投稿用ダイアログ

GAS側コード

//ターゲットファイルの設定
var webhook = "ここにwebhook URLを記入してください。"

//APIを叩くルーチン
function graphapicall(method, eUrl, payload) {
  //Graph APIサービスを取得する
  var service = checkOAuth();
  
  if (service.hasAccess()) {
    //HTTP通信
    var response = UrlFetchApp.fetch(eUrl, {
      headers: {
        Authorization: "Bearer " + service.getAccessToken()
      },
      method: method,
      contentType: "application/json",
      payload : JSON.stringify(payload)
    });
    
    //取得した値を返す
    return JSON.parse(response.getContentText());
  }else{
    //エラーを返す(認証が実行されていない場合)
    return "error";
  }
}

//Teamsのチャットにエントリーを投稿する
function postTeamsChat(title,chat){
  //スプレッドシートを取得する
  var ui = SpreadsheetApp.getUi();
  var ss = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("シート1")

  //Graph APIサービスを取得する
  var service = checkOAuth();
 
  if (service.hasAccess()) {
    //送信内容を組み立てる
    var payload = {
      'title'    : title,
      'text'       : chat,
    };
  
    //Teamsに投稿する
    var teams = graphapicall("POST", webhook, payload);
  
    //終了処理
    ui.alert("チャットを投稿しました。");
    
    //スプレッドシートに書き込む
    var array = [new Date(),title,chat];
    ss.appendRow(array);
    
  }else{
    ui.alert("認証が実行されていませんよ。");
  }
}
  • 今回のスクリプトは、graphapicall関数にpayloadの引数を1つ追加しています。
  • payloadには、タイトルとチャット内容を含めてPOSTで送り込みます。

Graph APIを叩くケース

こちらは、OAuth2認証が必要になります。そのため、複雑なコードが必要ですが、非常に細かなTeams操作が可能になります。

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

OAuth2 for Apps Scriptのページの「Create the OAuth2 Service」にあるコードを元に、Google Apps Script側で構築をします。この時、Microsoft365側で取得したアプリケーションIDやシークレットを使います。また、今回はいつもよりも要求するアクセス権限が多い点に注意が必要です

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 = "profile User.Read offline_access Files.ReadWrite ChannelMessage.Send"
var endpoint = "https://graph.microsoft.com/1.0/"
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/1.0となります。
  • 要求する権限は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が格納された

Graph APIを叩く関数

//TeamsチャンネルURL
var channelurl = "ここにチャンネルURLを入力";

//Teams送信
function sendtest(){
    var word = "メッセージ送信テスト";
    
    sendTeams(word);
}

//Teamsにメッセージを送るメインルーチン
function sendTeams(word){
    var ui = SpreadsheetApp.getUi();
    
    //Graph APIサービスを取得する
    var service = checkOAuth();
    
    //groupId,channelIdを取得する
    var param = checkTeams();

    //送信メッセージを構築
    var body = {
      "importance": "high",
      "subject": "メッセージタイトル",
      "body": {
        "content": word + "\n<at id = 0>メンション名称</at>",
        "contentType": "html"
      },
      "mentions": [
        {
          "id": 0,
          "mentionText": "メンション名称",
          "mentioned": {
            "conversation": {
                "id": param[1],
                "displayName": "メンション名称",
                "conversationIdentityType@odata.type": "#Microsoft.Teams.GraphSvc.conversationIdentityType",
                "conversationIdentityType": "channel"
            }
          }
        }
      ]
    }

    if(service.hasAccess()) {
        //エンドポイントURLを構築する
        var url = endpoint + param[0] + "/channels/" + param[1] + "/messages"
        
        //HTTPリクエスト
        var response = UrlFetchApp.fetch(url, {
          headers: {
            Authorization: "Bearer " + service.getAccessToken()
          },
          method: "POST",
          contentType: "application/json",
          body: body,
          muteHttpExceptions : true
        });
    
        //取得した値を返す
        return response.getContentText();
        
    }else{
        ui.alert("認証が実行されていませんよ。");
    }
}

//TeamsチャンネルURLの分解テスト
function checkTeams(){
  //TeamsチャンネルURLを取得する
  var teams = channelurl;

  //groupidを取り出す
  var name = "groupId"
  var groupId = getParam(name,teams);

  //channelIdを取り出す
  name = "threadId"
  var channelId = getParam(name,teams);

  //パラメータを表示
  return [groupId,channelId];
}

//URLパラメータから各種値を取り出す
function getParam(name,url){
  // パラメータを格納する用の配列を用意
  var paramArray = [];

  // URLにパラメータが存在する場合
  name = name.replace(/[\[\]]/g, "\\$&");
  var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"),
      results = regex.exec(url);
  if (!results) return null;
  if (!results[2]) return '';

  //パラメータを返す
  return decodeURIComponent(results[2].replace(/\+/g, " "));
}
  • メール送信はPOSTメソッドを使って送る必要があります。
  • urlfetchAppのQuotaはGsuite Basicの場合、100,000回/日呼び出し以外にも、連続して利用する場合には同一アカウントで実行時に10秒間に2アクセスが限界(それ以上になると、3回目のアクセスで403エラーや429エラーが発生する)。sleep処理が必須です。
  • TeamsのチャンネルURLからgroupIdとchannelIdを取り出す関数を別途用意して利用しています。
  • bodyにimportantをつけると、メッセージ重要度を変更することができます。Highで重要というメッセージに変わります。
  • メンションを送る場合には、bodyのcontentTypeはhtmlでなければなりません。
  • bodyのcontentにメッセージ本文を入れます
  • mentionsは基本このままのスタイルでつけると、そのチャンネル全員に対してメンションになります。
  • conversationのidはchannelIdと同じIDを利用します。これが送信先チャンネルへのメンションとなります。
  • メンションを使う場合は、bodyのcontentに必ず<at id = 0>メンション名称</at>を加えるようにします。そうでないとメンションを送れません。

実行と結果

まずはともあれ、認証を実行しAccess Tokenを実行しましょう。メニューより「OAuth認証」⇒「認証の実行」をして、Microsoft365側の認証を取得しておきます。その後、メニューより、「Teams投稿」⇒「エントリーの投稿」を実行すると、チャット投稿用のダイアログが出現します。ここにタイトルとチャット内容を投稿することで、Teamsエントリーが投稿されます。

投稿内容は、スプレッドシートにも記録として書き込みがなされます。

図:OAuth認証の実行画面。承諾しましょう。

図:Webhookからの投稿が反映しています。

図:Graph APIからの投稿が反映しています。

個人宛にチャットを送る場合

事前準備

前項までの方法は、チャネル宛にメッセージを送る方法です。個人(もしくは複数名)宛にチャットを送信したいケースは結構あると思いますが、そのためにはChatMessageのAPIを利用する必要性があります。しかし、送信するには「Chat-ID」が必要であり、このIDの取得が結構厄介です。よって予め、データベースとして対象のメアドを元に

  1. 対象ユーザのユーザIDを取得する(例:1234dcee-5678-9a10-bc11-12e1dfad314bのような文字列が取得できる)- GETメソッド
  2. ユーザIDを元に自身のユーザIDと対象ユーザのIDの2つ以上を元にチャットの作成(例:19:1234dcee-5678-9a10-bc11-12e1dfad314b_2468dff1-ba01-2ea1-a4bd-1d82022f2428@unq.gbl.spacesのようなChat-IDが取得できる)。この文字列は、19:自身のユーザID_相手のユーザID@unq.gbl.spacesで構築できる(ユーザIDの区切りはアンダーバー) - POSTメソッド
  3. このChat-IDを元にメッセージ送信をJSON形式のBodyでメッセージ指定をして送信 - POSTメソッド

の手順で送信が必要です。1:1であれば、2.のChat-IDは簡単に行えるので、実質、3.の送信部分が壁になります。

※1:1の場合は、@unq.gbl.spacesのようなChat-IDになりますが、複数名でのグループチャットの場合は、19:xxxxxxxxxxxxxxx@thread.v2といった@thread.v2のついたChat-IDになるので、次項のルーム作成で取得させます。

グループチャットのIDを取得する

1:1の場合は、ユーザID1_ユーザID2でつなげるだけで良いので、簡単にチャットを送信可能なのですが、自分含めて3名以上のグループチャット(チャンネルではない)を作成したい場合は、前述のように最後が@thread.v2となるChat IDを生成する必要があります。このChat IDはルームを作成すると生成されるのでその時のレスポンスデータから取得可能です。よって、グループチャットの場合は送信の為に2回APIを叩く必要があります。

//グループチャットを作成してChatIDを取得する
function makeGroupChat(){
    var ui = SpreadsheetApp.getUi();
    
    //Graph APIサービスを取得する
    var service = checkOAuth();

    //送信メッセージを構築
    var body = {
      "chatType": "group",
      "topic": "グループのタイトル名を入力",
      "members": [
        {
          "@odata.type": "#microsoft.graph.aadUserConversationMember",
          "roles": ["owner"],
          "user@odata.bind": "https://graph.microsoft.com/v1.0/users('自分のユーザIDを入力')"
        },
        {
          "@odata.type": "#microsoft.graph.aadUserConversationMember",
          "roles": ["owner"],
          "user@odata.bind": "https://graph.microsoft.com/v1.0/users('ユーザ1のユーザIDを入力')"
        },
        {
          "@odata.type": "#microsoft.graph.aadUserConversationMember",
          "roles": ["owner"],
          "user@odata.bind": "https://graph.microsoft.com/v1.0/users('ユーザ2のユーザIDを入力')"
        }
      ]
    }

    if(service.hasAccess()) {
        //エンドポイントURLを構築する
        var url = "https://graph.microsoft.com/v1.0/chats"
        
        //HTTPリクエスト
        var response = UrlFetchApp.fetch(url, {
          headers: {
            Authorization: "Bearer " + service.getAccessToken()
          },
          method: "POST",
          contentType: "application/json",
          body: body,
          muteHttpExceptions : true
        });
    
        //取得した値を返す
        return response.getContentText();
        
    }else{
        ui.alert("認証が実行されていませんよ。");
    }
}
  • Bodyの中に必ず自分のユーザIDも含めて3名以上のユーザIDを元にメンバーを加える
  • POSTにてhttps://graph.microsoft.com/v1.0/chatsに対してリクエストを送る
  • レスポンスの中のIDを含めたデータが入ってるので、これを取り出す(getContentTextで一旦取得してから取り出す)
{
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#chats/$entity",
    "id": "19:12345678910-tomatao@thread.v2",
    "topic": "グループのタイトル名",
    "createdDateTime": "2020-12-04T23:11:16.175Z",
    "lastUpdatedDateTime": "2020-12-04T23:11:16.175Z",
    "chatType": "group"
}

チャットの送信

Chat-IDを取得することが出来たので、これを元に以下の手順でGraph Explorerでテストしてみます。

  1. Graph Explorerを開き、組織アカウントでログインしておく
  2. 送信メソッドはPOSTを指定
  3. v1.0を指定
  4. https://graph.microsoft.com/v1.0/chats/chat-id/messages をクエリ欄に入力
  5. 要求ヘッダは、キーはContent-Type、値はapplication/jsonを指定する
  6. アクセス許可の修正にて、Chat.ReadChat.ReadWriteに許可を与える
  7. 要求本文は、以下のようなJSON形式でメッセージを入れておく
{
    "body": {
        "contentType": "html",
        "content": "<at id='0'>名無し権兵衛</at><at id='1'>すこふぃーるど</at><br><br>プリズン・ブレイク最高"
    },
    "mentions": [
        {
            "id": 0,
            "mentionText": "名無し権兵衛",
            "mentioned": {
                "user": {
                    "displayName": "名無し権兵衛",
                    "id": "ここにユーザのIDを入れる",
                    "userIdentityType": "aadUser"
                }
            }
        },
        {
            "id": 1,
            "mentionText": "すこふぃーるど",
            "mentioned": {
                "user": {
                    "displayName": "すこふぃーるど",
                    "id": "ここにユーザのIDを入れる",
                    "userIdentityType": "aadUser"
                }
            }
        }
    ]
}
  • メンションを使う場合は、bodyのcontentに必ず<at id = 0>メンション名称</at>を加えるようにします。そうでないとメンションを送れません。
  • Channelの時と同様に、メンションを送る場合は、mentionsにユーザ分構築が必要。それらを前述のatタグのIDで呼び出す。

なお、これをGraph APIで叩く場合には、AzureADのAPIのアクセス許可では、

  • Chat.Read
  • Chat.ReadWrite
  • Chat.Message.Send

の3つを追加しておく必要性があります。いずれも管理者の承認は不要です。これを元に、前述の認証を行う処理を作成するの項目にて、Scopeに追加しておき認証を実行。関数の中では、エンドポイントとして4.のクエリ、bodyに要求本文を追加してHTTPリクエストで叩けば、Node.jsから個人宛にチャット送信が可能です。

※トークンリフレッシュ時のコードにもscopeがあるので追記が必要です。

図:APIのアクセス許可は複数必要

図:Graph Explorerでメッセージ送信テスト

アダプティブカード

Teamsには通常のアプリ上での投稿では実現できない「アダプティブカード」という機能が搭載されてます。Power AutomateやNode.jsなどの言語からGraph APIを叩くことで利用する事が可能で、Teamsのチャット上でボタンやリストを構築し、例えば承認用画面を作ったり、株価などのような複雑な表記を実現することが可能になります。また、Markdown記法を利用して、HTMLでは未対応のテーブルを実現するなどが可能になるため、社内システムでTeamsを利用するならばぜひ採用したい機能の1つです。

ソースコード例

//メッセージ用のJSONを構築
var sdata = {
    "body": {
      "contentType": "html",
      "content": "<attachment id=\"tomato_card\"></attachment>"
    },
    "subject":"投稿タイトルをここに入力",
    "importance":"high",
    "attachments": [
      {
        "id": "tomato_card",
        "contentType": "application/vnd.microsoft.card.adaptive",
        "content": adaptive
      }
    ]
}
  • 通常の投稿と異なり、contentにはHTMLではなく、attachment idとして、adaptive cardのIDを指定して添付ファイルとしてカードを加える
  • attachment idは自分でわかるものを適当につける(通常はGUIDなどを使いますが)
  • attachments項目では、IDとcontent typeとしてapplication/vnd.microsoft.card.adaptiveを指定する
  • attachmentsのcontentには構築したアダプティブカードのJSONを指定する(次項参照)

図:こんな感じでボタンでユーザのアクションを取得できます

Adaptive Cardのコード

アダプティブカードだけでも1個の独立したエントリーを作れるほど内容が深いので、ここでは一例ですが、Markdownでのテーブル表記を利用したアダプティブカードを作ってみます。アダプティブカードはこちらのサイトからGUIで構築する事も可能です。また、各種サンプルからデザイナーで加工も可能になっています。

{
    "type": "AdaptiveCard",
    "$data": {
        "expenses": [
            {
                "expense_id": "16367000000083065",
                "description": "とまと",
                "created_time": "2019-06-19T18:33:12+0800"
            },
            {
                "expense_id": "16367000000083065",
                "description": "しいたけ",
                "created_time": "2019-06-19T18:33:12+0800"
            },
            {
                "expense_id": "16367000000083065",
                "description": "カニ味噌",
                "created_time": "2019-06-21T18:33:12+0800"
            }
        ]
    },
    "body": [
        {
            "type": "Container",
            "items": [
                {
                    "type": "ColumnSet",
                    "columns": [
                        {
                            "type": "Column",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "size": "ExtraLarge",
                                    "text": "📆 月中勤怠通知",
                                    "wrap": true
                                }
                            ],
                            "width": "stretch"
                        }
                    ]
                }
            ]
        },
        {
            "type": "Container",
            "spacing": "None",
            "style": "accent",
            "items": [
                {
                    "type": "ColumnSet",
                    "columns": [
                        {
                            "type": "Column",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "weight": "Bolder",
                                    "text": "日付",
                                    "wrap": true,
                                    "color": "Attention",
                                    "spacing": "Large"
                                }
                            ],
                            "width": "80px"
                        },
                        {
                            "type": "Column",
                            "spacing": "Large",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "weight": "Bolder",
                                    "text": "本文をここに記述します",
                                    "wrap": true,
                                    "color": "Attention"
                                }
                            ],
                            "width": "stretch"
                        }
                    ]
                }
            ],
            "bleed": true
        },
        {
            "$data": "${expenses}",
            "type": "Container",
            "items": [
                {
                    "type": "ColumnSet",
                    "columns": [
                        {
                            "type": "Column",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "text": "${formatDateTime(created_time, 'yyyy-MM-dd')}",
                                    "wrap": true
                                }
                            ],
                            "width": "80px"
                        },
                        {
                            "type": "Column",
                            "spacing": "Medium",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "text": "${description}",
                                    "wrap": true
                                }
                            ],
                            "width": "stretch"
                        }
                    ]
                }
            ],
            "style": "default"
        }
    ],
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "version": "1.2",
    "fallbackText": "This card requires Adaptive Cards v1.2 support to be rendered properly."
}
  • $dataの項目がテンプレートに流し込むためのデータの塊。body以下でそれらを変数(例:${expense})といった形で参照して、レコードをレンダリングします
  • body以下に上から順番にカードを構築していきますが、テーブルではHTMLのtableタグなどは使えないので、columnsを利用して構築します。
  • ただし、Graph APIでは上記の記法では、そのまんまデータが流し込まれずテンプレートのまま出力されてしまうため、実際にはデータ部分を別途items以下にレコードの数だけ、表記を生成してあげる必要があります(以下のような感じで変数を参照する型ではない形でデータを生成し、本体と結合していくスタイル)
  • Teamsで使う場合には、adaptive cardのVersion指定はv1.2で指定する必要があります。
{
    "type": "Container",
    "spacing": "Large",
    "style": "accent",
    "items": [
        {
            "type": "ColumnSet",
            "columns": [
                {
                    "type": "Column",
                    "items": [
                        {
                            "type": "TextBlock",
                            "weight": "Bolder",
                            "text": "日付",
                            "wrap": true,
                            "color": "Attention",
                            "spacing": "Large"
                        }
                    ],
                    "width": "80px"
                },
                {
                    "type": "Column",
                    "spacing": "Large",
                    "items": [
                        {
                            "type": "TextBlock",
                            "weight": "Bolder",
                            "text": "内容",
                            "wrap": true,
                            "color": "Attention"
                        }
                    ],
                    "width": "stretch"
                }
            ]
        }
    ],
    "bleed": true
},
{
    "type": "Container",
    "items": [
        {
            "type": "ColumnSet",
            "columns": [
                {
                    "type": "Column",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "2021-10-10",
                            "wrap": true
                        }
                    ],
                    "width": "80px"
                },
                {
                    "type": "Column",
                    "spacing": "Medium",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "とまと",
                            "wrap": true
                        }
                    ],
                    "width": "stretch"
                }
            ]
        }
    ],
    "style": "default"
},
{
    "type": "Container",
    "items": [
        {
            "type": "ColumnSet",
            "columns": [
                {
                    "type": "Column",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "2021-10-11",
                            "wrap": true
                        }
                    ],
                    "width": "80px"
                },
                {
                    "type": "Column",
                    "spacing": "Medium",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "しいたけ",
                            "wrap": true
                        }
                    ],
                    "width": "stretch"
                }
            ]
        }
    ],
    "style": "default"
},

 

図:こんな感じで投稿してくれます。

関連リンク

コメントを残す

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

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