Microsoft Teamsへメッセージを送るLooker Action

こんにちは。株式会社電通国際情報サービスのkumakuraです。
今回はBIツールLookerの外部連携機能である**Action**で[Microsoft Teams]( https://www.microsoft.com/ja-jp/microsoft-365/microsoft-teams/group-chat-software)（以下「Teams」）にメッセージを送る連携機能を作成してみました。


#目次
[1. Action/Action Hubとは](#1-actionaction-hubとは)
[2. Microsoft Teams APIのIncoming Webhookについて](#2-microsoft-teams-apiのincoming-webhookについて)
[3. 今回作成したActionについて](#3-今回作成したactionについて)
[4. 利用イメージ](#4-利用イメージ)
[5. 終わりに](#5-終わりに)




#1. はじめに
BIツールLookerでは分析して得た結果や条件に応じて、**Action Hub**と呼ばれるサーバを経由して
外部サービスにデータを連携することができます。
Looker内ではそれら外部サービス連携機能を**Acition**と呼びます。

Looker公式で[TwilioやSlackといった**Action**が数十種類公開](https://docs.looker.com/ja/admin-options/platform/actions)されていますが、プログラミングをすることで独自にActionを開発することも可能です。

独自のActionを開発する場合、
①. 「公式Node.jsフレームワークを用いる」方法
②. 「Action APIを自前で実装する」方法
の2つ方法があるのですが、今回は①公式Node.jsフレームワークを採用して、Teamsへ簡単なアラートを送るActionを開発していきたいと思います。

#2. Microsoft Teams APIのIncoming Webhookについて
ソースコードの解説の前に、今回使用した[Microsoft Teams APIのIncoming Webhook](https://docs.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook)について少し解説します。

Teamsにサーバからメッセージを送りたい場合、アプリを登録してアクセストークンを取得して〜とフローを踏む方法もありますが、シンプルに「特定のチャネルに対してメッセージを送りたい」場合、Incoming Webhookをチャネルに設定するのが一番手軽な方法かと思います。

Incoming Webhook（受信Webhook）はチャネルに設定できるコネクタの1つで、生成されたエンドポイントに対して[**Office 365 コネクタ カード形式**または**アダプティブカード形式**](https://docs.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using)のJSONを送ることでbotライクなメッセージを投稿することができます。

### Incoming Webhookの設定方法

1. Incoming Webhook を追加したいチャネルの、上部のナビゲーションバーから [コネクタ] を選択
![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878249/017a1e43-f084-0020-e9db-082c2f201900.png)


2. Incoming Webhook を検索し、[構成] ボタンを選択。（チームでIncoming Webhookが追加されていない場合、[追加]ボタンを押下してチームにアプリを追加してください。
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878249/c39c9a09-8286-01ca-9a9d-48ba98f04358.png)


3. 名前を入力し、オプションで Webhook 用の画像アバターをアップロードし、[作成]ボタンを押下します。
![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878249/50299fa9-ee09-309d-aa94-a0f7d8c37dc5.png)


4. URLが生成されます。生成されたエンドポイントに**Office 365 コネクタ カード形式**または**アダプティブカード形式**のJSONを送ることでメッセージを送信できます。
![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878249/9536283a-bc05-64a5-cd83-fd550856084e.png)


5. [完了]ボタンを選択します。 


#3. 今回作成したActionについて

今回、LookerからTeamsのIncoming Webhookに対してタイトルと本文、Lookerへのリンクを含んだメッセージを送るActionを作成していきます。
LookerではActionをスケジューリング機能を用いて日次で実行したり、フィルターを設定することで閾値を超えたデータが発生した場合に実行をすることができます。（勿論オンデマンドで実行もできます。）
今回のActionは「月初に特定のレポートを通知する」「異常値が発生した場合通知する」ユースケースを想定して作成しました。

また、今回はLooker公式のNode.jsフレームワークを用いてActionを作成したいと思います。
フレームワークはTypeScriptで書かれています。

##ディレクトリ構成
公式フレームワークを用いてActionを開発する場合、`(root) > src > actions`配下にフォルダを配置し、その中にTypeScriptのソースコードを配置します。
TeamsのActionを開発した場合ディレクトリ構成は次のようになります。（かなり省略しています）

```
actions/
　├ bin/
　├ ci/
　├ docs/
　├ public/
　├ src/
　│　├ actions/
　│　│　└ teams
　│　│　　　├ README.md
　│　│　　　├ teams.png　(アイコン画像)
　│　│　　　├ teams.ts　　（ソースコード本体）
　│　│　　　└ test_teams.png　（テストクラス）
　│　├ api_types/
　│　├ crypto/
　│　├ hub/
　│　├ server/
　│　├ xpc/
　│　└ boot.ts
　├ test/
　~
```
　
　
　

## ソースコード

### Actionの定義情報を実装する

まずはActionの定義情報を実装していきます。

```typescript
// teams.ts

import * as winston from "winston";
import * as Hub from "../../hub";
import * as httpRequest from "request-promise-native";

export class TeamsAction extends Hub.Action {
  name = "teams_incomingwebhook";
  label = "Teams - Incoming Webhook";
  iconName = "teams/teams.png";
  description = "Send data to Teams Incoming webhook";
  supportedActionTypes = [Hub.ActionType.Query, Hub.ActionType.Dashboard];
  supportedFormats = [Hub.ActionFormat.Csv, Hub.ActionFormat.WysiwygPng];
  supportedFormattings = [Hub.ActionFormatting.Unformatted];
  supported_visualization_formattings = [
    Hub.ActionVisualizationFormatting.Noapply,
  ];
  params = [];
    
  async execute() {
  }

}

Hub.addAction(new TeamsAction());
```
今回はアラートでレポートをTeamsに送るユースケースを想定しましたので、Actionを実行できるタイプはQuery(Explore,Look)とDashboardに限定しました。また、Teamsへはデータの送信ではなく、LookerのLook/Explore/DashboardのURLを添付するので（Incoming Webhookの場合データ送付が難しい）ため、データのフォーマットも`Csv`(queryの場合)と`WysiwygPng`(Dashboard)に限定しました。
なお、ここで何も選択しない場合、ユーザはLookerで使用できるフォーマットの全てを選択することができます。
今回、QueryとDashbordに対応したフォーマットを1つだけ選択することでユーザにフォーマットを意識させることなく指定のフォーマットでActionを実行させることができます。

それぞれ対応したデータのフォーマットは以下のようになります
- Query : `Txt`,`Csv`,`InlineJson`,`Json`,`JsonLabel`,`JsonDetail`,`JsonDetailLiteStream`,`Xlsx`,`Html`
- Dashboard : `WysiwygPdf`,`AssembledPdf`,`WysiwygPng`,`CsvZip`

　
　
### form( )を実装する

次にTeamsへタイトルと本文を含んだメッセージをユーザへ入力させるフォームを作成していきます。
フォームの定義は上記クラスの中で`form`メソッドを用意し、その中で実装していきます。

```typescript

// teams.ts

  async form() {
    const form = new Hub.ActionForm();
    form.fields = [];
    form.fields.push({
      label: "Webhook URL",
      name: "webhookUrl",
      required: true,
      type: "string",
    });
    form.fields.push({
      label: "Title",
      name: "title",
      required: true,
      type: "string",
    });
    form.fields.push({
      label: "Text",
      name: "text",
      required: false,
      type: "textarea",
    });
    form.fields.push({
      label: "Attach Meta Data",
      description: "attach meta data(type,title,model,view)",
      default: "false",
      name: "isAttached",
      required: false,
      type: "select",
      options: [
        { name: "false", label: "false" },
        { name: "true", label: "true" },
      ],
    });

    return form;
  }

```


ユーザに入力させるフォームとして①TeamsのIncoming Webhookのエンドポイント、②メッセージのタイトル、③メッセージの本文、④メタデータの送信の可否　を考えてみました。
テキスト形式の入力以外にもセレクトボックスからユーザに値を入力させることができます。（上記の*Attach Meta Data*）

なお、Lookerインスタンス全体で1つのIncoming Webhookを利用する場合（1つのチャネルにだけ送信する場合）、Actionの定義の段階で`param`を用意し、Actionを有効化する際にユーザにWebhookのURLを入力させることで、実行のたびに毎回URLを入力せずに済むのですが、**Lookerインスタンス全体で複数のチャネルに対してメッセージを送るケースが多そうな事**と、**今回想定しているユースケースはアラート/スケジュール利用なので頻繁にフォームから実行されるケースが少なさそうな事**を考えて、Actionを実行する際に毎回設定する方針を採用しました。

　
　
### execute( )を実装する
　
最後に、Actionが実行された後の具体的な処理を実装していきましょう！
Actionが実行された後の処理は`execute`メソッド内で実装していきます。

```typescript

// teams.ts

  async execute(req: Hub.ActionRequest) {
    let response = { success: true, message: "success" };

    if (!req.formParams.webhookUrl) throw new Error("Need a webhookUrl");
    if (!req.formParams.title) throw new Error("Need a title");
    if (!req.formParams.isAttached) throw new Error("Need a attach flag");
    if (!req.scheduledPlan)
      throw new Error("Couldn't get data from scheduledPlan");

    const webhookUrl = req.formParams.webhookUrl;
    const title: string = req.formParams.title;
    const text: string =
      req.formParams.text === undefined
        ? ""
        : req.formParams.text.replace(/\n/g, "\n\n");

    const resCard = {
      "@type": "MessageCard",
      "@context": "http://schema.org/extensions",
      themeColor: "5035b4",
      summary: "Looker Reports",
      title: title,
      text: text,
      sections: [] as any,
      potentialAction: [
        {
          "@type": "OpenUri",
          name: "View in Looker",
          targets: [{ os: "default", uri: req.scheduledPlan.url }],
        },
      ],
    };

    if (req.formParams.isAttached === "true") {
      const facts = [];
      facts.push({
        name: "Type :",
        value: req.scheduledPlan.type,
      });
      facts.push({
        name: "Title :",
        value: req.scheduledPlan.title,
      });
      if (req.type === Hub.ActionType.Query && req.scheduledPlan.query) {
        facts.push({
          name: "Model :",
          value: req.scheduledPlan.query.model,
        });
        facts.push({
          name: "View :",
          value: req.scheduledPlan.query.view,
        });
      }
      resCard.sections.push({
        facts: facts,
      });
    }

    const option = {
      url: webhookUrl,
      json: resCard,
    };

    try {
      const result = await httpRequest.post(option).promise();
      if (result !== 1) {
        throw new Error(result);
      }
    } catch (e) {
      response = { success: false, message: e.message };
      winston.error(e.message);
    }
    return new Hub.ActionResponse(response);
  }
```

ユーザにフォームで入力してもらった情報を元にIncoming Webhookに対してPOSTを送っております。
また、POSTで送るJSONの形式としてOffice365コネクタカードの形式を採用しました。

処理の内容は至ってシンプルで、ユーザに入力していただいたIncoming WebhookのURLに対して、タイトル・本文・Lookerへのリンク（・メタデータ）をOffice365コネクタカードの形式に整形を行いPOSTする、という内容になっています。


#4. 利用イメージ

Lookerでユーザに入力させるフォームは次のようになります。
![image1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878249/735cc39d-106f-6077-7f01-94e0624c2e08.png)


上の画像のようにユーザがフォームを入力した場合、Teamsには次のようなメッセージが投稿されます。
![スクリーンショット 2020-12-18 3.41.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878249/4a718913-0d2a-8135-d847-5cfbfd220f67.png)
今回、フォームの段階で*Attach Meta Data*がtrueになっているので、Teams上のメッセージにメタデータが添付されています。


#5. 終わりに

今回のTeamsのActionは、Looker公式のリポジトリにプルリクエストを送ることを想定して、公式のフレームワークで作成しています。
プルリクエストが承認された場合、皆さんもLookerから利用できるようになりますので、（シンプルな機能ではありますが）是非利用してみてください。
今回の記事が皆さんのAction開発の一助になれれば幸いです。ここまで読んでいただきありがとうございました

---

### 追記(2021/12/10)
いつの間にかLooker公式サイトでも紹介されてました
https://looker.com/product/new-features
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878249/e3be422c-f928-079d-2073-0244a1233c95.png)