1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PHONE APPLIAdvent Calendar 2024

Day 19

[Microsoft Teams] APIベースのメッセージ拡張機能を使ってみる

Last updated at Posted at 2024-12-19

はじめに

2024年の春頃にAPIベースのメッセージ拡張機能が、GAになっています。
(↓参考として、GAした頃の開発者ブログのリンクを貼っています)

過去メッセージ拡張機能を使う機会はあったのですが、APIベースのメッセージ拡張機能を使ってみたことはありませんでした。
今回学習を兼ねて、記事に書こうと思います!

2024年12月時点での内容になります。
最新の情報は、Microsoft 公式ドキュメントをご確認ください。


メッセージ拡張機能とは

メッセージ拡張機能とは、 Microsoft Teams アプリケーションの実装方法のひとつです。
メッセージ拡張機能を利用することで、3rd Party製のWebアプリを Microsoft Teams のユーザーエクスペリエンスに組み込むことができます!

公式のドキュメントでは、メッセージ拡張機能を以下のように説明されています。

メッセージ拡張機能を使用すると、ユーザーはMicrosoft Teams クライアント内のボタンとフォームを使用して Web サービスと連携できます。 ユーザーは、外部システムのメッセージ作成領域、コマンド ボックスから、またはメッセージから直接、操作を検索したり、開始したりできます。 これらの操作の結果は、リッチフォーマットのカードとして Teams クライアントに返すことができます。

Microsoft Teams や Microsoft 365 製品との相乗効果

元々は Microsoft Teams 上でのみ機能していましたが、ある時期から Outlook や Microsoft 365 アプリでも使えるようになり、カバーできる幅が年々増えてきています。

直近ではメッセージ拡張機能を使用することで、法人向けのMicrosoft 365 Copilot と連携することもできます。

(↓ 宣言型エージェント(Declarative agents)の拡張としてPlugins にメッセージ拡張機能(Message extensions) が含まれていることがわかります)

Microsoft Copilot UX Enxtensibility

メッセージ拡張機能作成方法

公式ドキュメントでは メッセージ拡張機能には2種類の作成方法が案内されています。
Open APIを使用して開始する方法」と「Bot Framework を使用して開始する方法」です。
Bot Framework に関しては以前から選択できた方法です。

今回は、1つ目のOpen APIを使用して開始する方法を選択します!

Which Message Extension option should you use?


APIベースでメッセージ拡張機能を作成する

ドキュメントを確認してある程度情報をインプットしたうえで、実際にメッセージ拡張機能のサンプルアプリを作成していきたいと思います。

ドキュメントを見てみる

こちらのドキュメントを見ていきます。

APIベースのメッセージ拡張機能の特徴をまとめると2つほどありそうです。

  • ボットの登録が不要である
  • メッセージ拡張機能のうち検索コマンドに対応している

公式ドキュメントにおいて、Bot Framework を使用した作成方法は、ボットベースのメッセージ拡張機能と表現されています。
これ以降は、公式ドキュメントにちなんで「APIベース」と「ボットベース」と表現していきます。

[特徴1] ボットの登録が不要

ボットベースの場合には、Microsoft Azure に ボットを登録する必要があります。メッセージ拡張機能は、ボットを媒介して 3rd Party製アプリと連携することになります。
実装としては、 3rd Party製アプリに BotHandler を設定してリクエストを受け付けられるようにしておきます。BotHandlerは、BotFramework SDK で提供されているものがあり、こちらを利用することで メッセージ拡張機能で検索コマンドが実行された場合に、3rd Party製アプリがどのように振る舞うかをイベントごとに記載しておくことが可能です。(BotActivityHandler を継承している TeamsActivityHandler を使うことが多いです)

一方APIベースの場合には、このボットの登録自体が不要になるようです!
上でつらつらと書いていた内容は、APIベースの場合は不要です。(なんと!)
ウェブエンジニアには親しみ深い Open APIを使ってエンドポイントを設定でき、3rd Party製アプリと直接HTTP通信が行えるようです。

[特徴2] 検索コマンドにのみ対応

ボットベースの場合には、どのメッセージ拡張機能(操作コマンド、検索コマンド、リンク展開)も作成が可能です。

リンク展開は、ユーザーがURLをペーストした場合に、一意となるアダプティブカードを生成してサムネイルようなものとして表示する機能です。
操作コマンドは、ざっくりいうとフォームを使用したユーザアクションになります。
リンク展開や、操作コマンドは、APIベースの場合には作成できず、あくまで検索コマンドのみの対応のようです。
APIベースは、Open API を使用して柔軟な実装ができる反面、テリトリーが狭いといったイメージでしょうか。

APIベースのメッセージ拡張の検索コマンド時のイベントシーケンス

公式ドキュメントには、APIベースの場合のイベントシーケンスが記載されています。

クエリ コマンドの呼び出し中に発生するイベントの概要を次に示します。

  1. ユーザーがクエリ コマンドを呼び出すと、クエリ コマンドのパラメーターが Teams Bot Serviceによって受信されます。
  2. クエリ コマンドは、アプリ マニフェスト ファイル内で定義されます。 コマンド定義には、OpenAPI 仕様ファイル内の operationId への参照と、そのコマンドに対して Teams クライアントがレンダリングするパラメーターの詳細が含まれています。 参考までに、 OpenAPI 仕様ファイル内の operationId は、特定の HTTP 操作に固有です。
  3. その後、Teams Bot Serviceは、ユーザーが指定したパラメーターと、関連付けられている operationId の OpenAPI 仕様のコピーを使用して、開発者のエンドポイントの HTTP 要求を構築します。
  4. 認証が必要で、マニフェストで構成されている場合、 適切なトークンまたはキーに解決されます。 このトークンまたはキーは、送信要求の一部として使用されます。 [任意]
  5. Teams ボット サービスは、開発者のサービスに対して HTTP 要求を実行します。
  6. 開発者のサービスは、OpenAPI 仕様に記載されているスキーマに従って応答する必要があります。 これは JSON 形式です。
  7. Teams クライアントは、結果をユーザーに表示し直す必要があります。 前の手順の JSON 結果を UI に変換するために、Teams ボット サービスは応答レンダリング テンプレートを使用して、結果ごとにアダプティブ カードを構築します。
  8. アダプティブ カードはクライアントに送信され、UI でレンダリングされます。

(ドキュメントの翻訳に怪しい箇所があったため、一部修正を入れて引用しています)

Query Command Seaquences with API-based ME

これらの情報から気になったのは、3点あります。

  • APIベースの場合、アプリマニフェストから参照できる範囲に Open APIの仕様を記載したファイルを含める必要があるようです
    • ドキュメントの他の箇所を見た感じ、同じアプリパッケージにアプリマニフェストとOpen API の仕様を同梱しているようです(下図)
      AppPackage contains Manifest and OpenAPIDoc
  • メッセージ拡張の検索コマンドの関数名に operationId を記載して、Open API の仕様を参照できるようにしておく必要があります
    • Open APIの仕様抽出する仕組みが気になっていましたが、やはり operationId を使用して紐づけるようですね
  • 3rd Party製アプリは、JSON形式でレスポンスを返し、Microsoft Teams がアダプティブカードを構築してユーザーに表示する
    • テンプレートファイルを同じアプリパッケージに同梱しておく必要があるみたいです

サンプルアプリを作成する

公式ドキュメントに目を通してある程度イメージがついてきたので、実際にサンプルアプリを試してみます。
Visual Studio Code の拡張機能にある Teams Toolkit を使用して、サンプルコードを少し変更してどんな感じか体験してみたいと思います。

サンプルアプリは、飲食店をキーワードで検索できるものにしてみました。
ホットペッパーグルメ の API をラップして、Microsoft Teams の メッセージ拡張機能の検索コマンドで呼び出せるようにします。
Teams Toolkit のサンプルが、API を Azure Functions 用に実装されているので、そのまま Azure Functions を使って実装しました。

実行環境

参考程度に今回試した環境を載せておきます

項目 バージョン
Node.js 20.18.1
NPM 11.0.0
TypeScript 5.7.2
OpenAPI 3.0.0
Teams Toolkit (VS Code) 5.12.0
@azure/functions 4.6.0

セットアップ

趣旨から外れる部分なので、ここはざっくり書きます。
Teams Toolkit のプロジェクトテンプレートを使用してサンプルアプリを作成します。
DEVELOPMENT の [新しいアプリを作成する] を押すと、オプションを選択できます。
今回は、[メッセージ拡張機能]、[カスタム検索結果]、[新しいAPIで開始] を順に選択して適当な名前をつけてプロジェクトを作成しました。

この状態で VS Code の Debug 画面を開き[Debug in Teams (Chrome)] 、[Debug in Teams (Edge)] を選んでデバッグを開始すると、ブラウザ上に Microsoft Teams が開いてアプリの追加ポップアップが表示されます。

メッセージ拡張機能が動くことを確認できます。

Open API の仕様を作成

以下のようなAPIを追加しました。
本当はいくつかメッセージ拡張コマンドを用意して、APIのパスを分岐させようと思っていましたが、Teams Toolkit のデバッグ起動時に処理がどうしても通らない部分があったため、今回はAPIを一つだけ追加することにしました。

${{OPENAPI_SERVER_URL}} は、アプリパッケージを作成する際に、Teams のトンネリング用のエンドポイントが埋め込まれるようです。

appPackage/apiSpecificationFile/restaurant.yml
openapi: 3.0.0
info:
  title: レストラン検索API
  description: |
    レストラン検索APIは、ホットペッパーグルメの店舗情報を取得するためのAPIです。

    Powered by <a href="http://webservice.recruit.co.jp/">ホットペッパーグルメ Webサービス</a>
  version: 1.0.0
servers:
  - url: ${{OPENAPI_SERVER_URL}}/api/restaurants
    description: Default API Endpoint

paths:
  /search-by-keyword:
    get:
      operationId: restaurant
      summary: キーワードで検索
      description: |
        キーワードを指定して、店舗情報を取得します。
        半角スペースで複数のキーワードを指定することができます。
      parameters:
        - name: keyword
          in: query
          description: 検索するキーワード(半角スペースで複数指定可)
          required: true
          schema:
            type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RestaurantList'

components:
  schemas:
    Restaurant:
      type: object
      properties:
        id:
          type: string
          description: レストランID
        name:
          type: string
          description: 店舗名
        address:
          type: string
          description: 住所
        stationName:
          type: string
          description: 最寄駅名
        open:
          type: string
          description: 営業時間
        holiday:
          type: string
          description: 定休日
        logoImage:
          type: string
          description: ロゴ画像URL
        siteUrl:
          type: string
          description: サイトURL
      required:
        - id
        - name
        - address
        - stationName
        - open
        - holiday
        - logoImage
        - siteUrl

    RestaurantList:
      type: object
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/Restaurant'
      required:
        - results

レスポンスのテンプレートファイルの作成

先ほど、Open API で追加したAPIのレスポンスボディをアダプティブカードに変換するためには、テンプレートファイルを使う必要があるみたいです。
以下のように書きました。

previewCardTemplate に設定したものが、検索結果の一覧に表示されるサムネイルとなり、responseCardTemplate に設定したものが、メッセージ入力欄に補完されるアダプティブカードとなります。

actions には、ホッとペッパー上の飲食店のページにアクセスするボタンを用意しています。

appPackage/responseTemplates/restaurants.json
{
  "version": "devPreview",
  "$schema": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.ResponseRenderingTemplate.schema.json",
  "jsonPath": "results",
  "responseLayout": "list",
  "responseCardTemplate": {
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "type": "AdaptiveCard",
    "version": "1.5",
    "body": [
      {
        "type": "TextBlock",
        "text": "レストラン情報",
        "weight": "Bolder",
        "size": "Large",
        "wrap": true
      },
      {
        "type": "Container",
        "items": [
          {
            "type": "ColumnSet",
            "columns": [
              {
                "type": "Column",
                "width": "stretch",
                "items": [
                  {
                    "type": "TextBlock",
                    "text": "レストラン名: ${if(name, name, 'N/A')} [${if(id, id, 'N/A')}]",
                    "wrap": true
                  },
                  {
                    "type": "TextBlock",
                    "text": "最寄駅: ${if(stationName, stationName, 'N/A')}",
                    "wrap": true
                  },
                  {
                    "type": "TextBlock",
                    "text": "住所: ${if(address, address, 'N/A')}",
                    "wrap": true
                  },
                  {
                    "type": "TextBlock",
                    "text": "営業時間: ${if(open, open, 'N/A')}",
                    "wrap": true
                  },
                  {
                    "type": "TextBlock",
                    "text": "定休日: ${if(holiday, holiday, 'N/A')}",
                    "wrap": true
                  }
                ]
              },
              {
                "type": "Column",
                "width": "auto",
                "items": [
                  {
                    "type": "Image",
                    "url": "${if(logoImage, logoImage, '')}",
                    "size": "Medium"
                  }
                ]
              }
            ]
          },
          {
            "type": "TextBlock",
            "text": "Powered by [ホットペッパーグルメ](http://webservice.recruit.co.jp/)",
            "wrap": true
          }
        ]
      }
    ],
    "actions": [
      {
        "type": "Action.OpenUrl",
        "title": "詳細を見る",
        "url": "${if(siteUrl, siteUrl, '')}"
      }
    ]
  },
  "previewCardTemplate": {
    "title": "${if(name, name, 'N/A')}",
    "subtitle": "${if(stationName, stationName, 'N/A')}",
    "image": {
      "url": "${if(logoImage, logoImage, '')}",
      "alt": "${if(name, name, 'N/A')}"
    }
  }
}

マニフェストファイルに参照ファイルの設定を追加

appPackage/manifest.json
{
    "$schema": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json",
    "manifestVersion": "devPreview",
    "id": "${{TEAMS_APP_ID}}",
    "version": "1.0.0",
    "developer": {
        "name": "Teams App, Inc.",
        "websiteUrl": "https://www.example.com",
        "privacyUrl": "https://www.example.com/privacy",
        "termsOfUseUrl": "https://www.example.com/termsofuse"
    },
    "icons": {
        "color": "color.png",
        "outline": "outline.png"
    },
    "name": {
        "short": "restaurantSearch${{APP_NAME_SUFFIX}}",
        "full": "Full name for restaurantSearch"
    },
    "description": {
        "short": "レストラン検索ができるメッセージ拡張機能アプリです",
        "full": "このアプリは、ホットペッパーグルメが公開しているAPIを使用して、レストラン検索ができるメッセージ拡張機能アプリです。 \n\n Powered by [ホットペッパーグルメ](https://webservice.recruit.co.jp/)"
    },
    "accentColor": "#FFFFFF",
    "composeExtensions": [
        {
            // apiBased を指定
            "composeExtensionType": "apiBased",
            // Open API の仕様書のファイルパスを指定
            "apiSpecificationFile": "apiSpecificationFile/restaurant.yml",
            "commands": [
                {
                    // Open API の対応するAPI に設定した operationId を指定
                    "id": "restaurant",
                    "type": "query",
                    "title": "キーワードでレストラン検索",
                    "description": "指定したキーワードでレストランを検索します。半角スペースで複数のキーワードを指定することができます",
                    // レスポンスに使用するアダプティブカードのテンプレートファイルのパスを指定
                    "apiResponseRenderingTemplateFile": "responseTemplates/restaurants.json",
                    // API に渡すパラメータを指定
                    "parameters": [
                        {
                        "name": "keyword",
                        "title": "キーワード",
                        "description": "検索したいキーワードを入力してください",
                        "inputType": "text"
                        }
                    ]
                }
            ]
        }
    ],    
    "permissions": [
        "identity",
        "messageTeamMembers"
    ]
}

APIの実装

必要な追加パッケージを入れておきます
ホットペッパーのAPIがどうやら、XML形式でレスポンスが返ってくるようなので、XMLを解析できるようにしておく必要があるみたいでした。

以下の記事を参考にさせていただきました。
axios, xml2js を使います。

また、OpenAPI の型定義を利用するために、 openapi-typescript も入れておきます。

npm install axios xml2js
npm install -D @types/xml2js openapi-typescript

package.json の scripts を以下のように変更しておきます。

package.json
{
    "scripts": {
        "dev:teamsfx": "npm run build && npm run watch:teamsfx & env-cmd --silent -f .localConfigs npm run dev",
        "dev": "func start --typescript --language-worker=\"--inspect=9229\" --port \"7071\" --cors \"*\"",
        "build": "tsc",
        "watch:teamsfx": "tsc --watch",
        "watch": "tsc -w",
        "prestart": "npm run build",
        "start": "npx func start",
        "generate:api-schema": "openapi-typescript ./appPackage/apiSpecificationFile/restaurant.yml --output ./src/.api-schema.d.ts",
        "prepare": "npm run generate:api-schema"
    },
}

HTTPトリガーのAzure Functions 用の関数を作成します。

src/functions/search-by-keyword.ts
import { app } from "@azure/functions";
import type { paths } from "../.api-schema";
import { HotpepperApiClient } from "../apiClients/HotpepperApiClient";

app.http("search-by-keyword", {
  methods: ["GET"],
  route: "restaurants/search-by-keyword",
  authLevel: "anonymous", // お試しなので、認証なし
  handler: async (req, context) => {
    context.log("HTTP trigger function processed a request.");

    // クエリパラメータからキーワードを抽出。UTF-16で送られてくるみたいなので、特殊なデコードが必要
    const keyword = req.query.has("keyword") ? decodeUnicode(req.query.get("keyword")) : "";

    // キーワードがない場合は、早期リターン
    if (!keyword) {
      const jsonBody: ResponseBody = {
        results: [],
      }

      return {
        status: 200,
        headers: {
          "Content-Type": "application/json",
        },
        jsonBody,
      };
    }
    const apiRes = await HotpepperApiClient.instance.searchRestaurantsByKeyword(keyword);

    const jsonBody: ResponseBody = {
      results: apiRes.results.shop?.map((shop) => {
        return {
          id: shop.id,
          name: shop.name,
          address: shop.address,
          stationName: shop.station_name,
          open: shop.open,
          holiday: shop.close,
          logoImage: shop.logo_image,
          siteUrl: shop.urls.pc,
        };
      }) || [],
    };

    return {
      status: 200,
      headers: {
        "Content-Type": "application/json",
      },
      jsonBody,
    };
  },
});

type ResponseBody = paths["/search-by-keyword"]["get"]["responses"]["200"]["content"]["application/json"];

function decodeUnicode(encodedStr: string): string {
  return encodedStr.replace(/%u([\dA-F]{4})/gi, (_, code) => 
      String.fromCharCode(Number.parseInt(code, 16))
  );
}

ホットペッパーのグルメサーチAPIを呼び出すAPIクライアントを作成します。
(異常系は考慮していません)

src/apiClients/HotpepperApiClient.ts
import axios, { AxiosInstance } from 'axios';
import * as xml2js from 'xml2js';


export class HotpepperApiClient {
  readonly #axios: AxiosInstance;
  constructor(apiKey: string = process.env.HOTPEPPER_API_KEY) {
    this.#axios = axios.create({
      baseURL: 'https://webservice.recruit.co.jp/hotpepper',
      params: {
        // APIキーは、https://webservice.recruit.co.jp/register/ から発行する必要がある
        key: apiKey,
      },
      transformResponse: [(data) => {
        // XMLをJSONに変換
        // こちらの記事を参考 https://qiita.com/fossamagna/items/411ec63cf62dc73784da
        let jsonData: any;
        const parser = new xml2js.Parser({
          async: false,
          explicitArray: false
        });
        parser.parseString(data, (error, json) => {
          if (error) {
            console.error(error);
            return;
          }
          jsonData = json;
        });
        return jsonData;
      }],
    });
  }

  async searchRestaurantsByKeyword(keyword: string): Promise<GourmetResponse> {
    const res = await this.#axios.get<GourmetResponse>('/gourmet/v1/', {
      params: {
        keyword,
      }
    });
    return res.data;
  }

  private static client: HotpepperApiClient;
  static get instance(): HotpepperApiClient {
    if (!this.client) {
      this.client = new HotpepperApiClient();
    }
    return this.client;
  }
}

export interface GourmetResponse {
  results: {
    api_version: string;
    results_available: number;
    results_returned: number;
    results_start: number;
    shop: Array<{
      id: string;
      name: string;
      logo_image: string;
      name_kana: string;
      address: string;
      station_name: string;
      ktai_coupon: number;
      large_area: {
        name: string;
      };
      middle_area: {
        name: string;
      };
      small_area: {
        name: string;
      };
      genre: {
        name: string;
      };
      budget: {
        average: string;
        name: string;
      };
      lunch: string;
      credit_card: string;
      e_money: string;
      urls: {
        pc: string;
      };
      open: string;
      close: string;
      party_capacity: string;
      wifi: string;
      mobile_access: string;
      mobile_phone: string;
      course: string;
      free_drink: string;
      free_food: string;
      private_room: string;
      horigotatsu: string;
      tatami: string;
      card: string;
      non_smoking: string;
      charter: string;
      ktai: string;
      parking: string;
      barrier_free: string;
      show: string;
      equipment: string;
      karaoke: string;
      band: string;
      tv: string;
      english: string;
      pet: string;
      child: string;
      lunch_memo: string;
      shop_detail_memo: string;
      coupon_urls: Array<{ [key: string]: string }>;
    }>;
  };
}

デバッグ実行

メッセージ拡張機能の一覧から今回作成したアプリを選択し、キーワードを入力すると検索結果が返ってくることが確認できました!

アイテムを選択すると以下のようなアダプティブカードが生成され、メッセージと一緒に送信できます!


まとめ

API ベースのメッセージ拡張機能のメリットを享受できるのは、既存のAPIとの連携かなとは思いました。Bot ベースに比べると手軽さは感じられます!
ただし、現時点で検索コマンドにおいてマルチパラメータに対応しているのが、Microsoft 365 Copilot 経由での実行に限定されているのが勿体無いなと思いました。

あとは、クエリパラメータがUTF-16にエンコードされてAPIが受け取っているようで、デコードに一癖あるなという印象です。ここら辺は Express.js などのウェブアプリのフレームワークを使用したら解消できる問題かもしれませんが、Azure Functions の場合は少し注意が必要かもしれません。

今回は認証に関してはノータッチですが、実運用を考えると認証周りの設定も組み込むことになると思いますので、手が空いた時に認証周りのドキュメントも確認しておこうかなと思います!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?