Microsoft 365 Copilot プラグインを作ってみた

NTTデータ先端技術株式会社の原田です。
とあるイベントの登壇ネタに Teams メッセージ拡張を使用した Microsoft 365 Copilot プラグインを作成してみましたので、自分自身の備忘も兼ねて記事にいたします。

## 条件・制約
* <font color="red"><strong>!!!!! コードの実行は自己責任でお願いします !!!!!</strong></font>
* Windows 10 22H2 にて開発・確認を行っています
* リンク先等の掲載内容は本稿執筆時点のものとなります

## Microsoft 365 Copilot プラグインとは
本稿には Microsoft 365 Copilot プラグインと Teams メッセージ拡張の2つの要素が含まれますので、前段としてそれぞれの概要について説明します。

### Microsoft 365 Copilot プラグイン
https://learn.microsoft.com/ja-jp/microsoft-365-copilot/extensibility/

Copilot に聞いてもピンとこなかったので私の理解を要約しますと「Microsoft 365 Copilot を拡張して外部データを参照させる仕組み」となります、まあ RAG かエージェントみたいなもんですかね。
この拡張方式にコネクターとプラグインの2種類があり、本稿ではプラグインの作成を行っています。

### Teams メッセージ拡張
https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions?tabs=desktop

Teams メッセージ拡張は Teams からボタンなどをトリガーにアクションを呼び出すことができる仕組みで、Web サービスを呼び出して結果をチャットに表示すると言った使い方が一般的だと思います。
このメッセージ拡張をプラグインにすると、プロンプトでアクションを呼び出して Copilot をエージェントっぽく使用することができるようになります。<br><br>

それでは次のセクションで開発環境の設定を進めていきます。

## 開発環境の設定
https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/prerequisites-message-extension-bot

https://learn.microsoft.com/en-us/microsoftteams/platform/sbs-messagingextension-searchcommand-plugin

基本的にはこのリンク先の手順にてテンプレートコードの実行まで進めることができますが、プラグインの起動手順がちょっとわかりにくいので補足しておきます。<br><br>

[プラグインの起動]
1. 適当なチャネル or チャットを開く
2. メッセージ入力欄で アクションとアプリ を表示
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620633/1d6aafed-89a5-ee01-1394-1e73334e0c6a.png)
3. プラグインを検索
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620633/08789464-c150-715c-8ff2-72ee0f698cc1.png)
4. プラグインが起動しメッセージを入力することができます
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620633/1614d7fb-1aa4-88be-802f-9f60ec079d00.png)

テンプレートでは Node.js のパッケージを検索してアダプティブカードを表示するコードが実装されています。
```searchApp.ts (抜粋)
public async handleTeamsMessagingExtensionQuery(
    context: TurnContext,
    query: MessagingExtensionQuery
  ): Promise<MessagingExtensionResponse> {
    const searchQuery = query.parameters[0].value;
    const response = await axios.get(
      `http://registry.npmjs.com/-/v1/search?${querystring.stringify({
        text: searchQuery,
        size: 8,
      })}`
    );

    const attachments = [];
    response.data.objects.forEach((obj) => {
      const template = new ACData.Template(helloWorldCard);
      const card = template.expand({
        $root: {
          name: obj.package.name,
          description: obj.package.description,
        },
      });
      const preview = CardFactory.heroCard(obj.package.name);
      const attachment = { ...CardFactory.adaptiveCard(card), preview };
      attachments.push(attachment);
    });

    return {
      composeExtension: {
        type: "result",
        attachmentLayout: "list",
        attachments: attachments,
      },
    };
  }
}
```

## プラグインの仕組み
ここで少しプラグインの仕組みを説明しておきたいと思います。
先程のプラグインのフライアウトでメッセージを入力すると、
```
{
  commandId: 'findNpmPackage',
  parameters: [ { name: 'NpmPackageName', value: 'Hello' } ],
  queryOptions: { count: 25, skip: 0 }
}
```
と json パラメーターがプラグインに引き渡されます、この中の Parameters[0].Value がパッケージのサーチで使用されている訳ですね。
ではこのメッセージを構造化する定義がどこにあるかと言うと、それは manifest.json の中に記載されています。
```manifest.json (抜粋)
"composeExtensions": [
        {
            "botId": "${{BOT_ID}}",
            "commands": [
                {
                    "id": "findNpmPackage",
                    "context": [
                        "compose",
                        "commandBox"
                    ],
                    "description": "Find npm package according to the npm package name",
                    "title": "Find Npm Package",
                    "type": "query",
                    "semanticDescription": "This command retrieves detailed information about an npm package using the provided npm package name.",
                    "parameters": [
                        {
                            "name": "NpmPackageName",
                            "title": "Npm Package Name",
                            "description": "The name of the npm package to be searched",
                            "inputType": "text",
                            "semanticDescription": "This parameter is used to identify the specific npm package to be queried. Users should provide the exact name of the npm package they want to retrieve information for as the value of this parameter."
                        }
                    ]
                }
            ]
        }
    ],
```
この定義に沿って、入力されたメッセージが id:findNpmPackage の name:NpmPackageName だと判定して json に変換が行われます。
プラグインの開発はここでご説明しました searchApp.ts と manicest.json に手を入れることで進めていくのが基本形となります。<br><br>

では次のセクションでコードをいじっていきましょう。

## アクションの追加
サンプルとしてプラグインの開発者情報を照会するアクションを作成していきます。
まずは manifest.json にコマンド定義の追加を行います。
```diff:manifest.json (抜粋)
"composeExtensions": [
        {
            "botId": "${{BOT_ID}}",
            "commands": [
                {
                    "id": "findNpmPackage",
                    "context": [
                        "compose",
                        "commandBox"
                    ],
                    "description": "Find npm package according to the npm package name",
                    "title": "Find Npm Package",
                    "type": "query",
                    "semanticDescription": "This command retrieves detailed information about an npm package using the provided npm package name.",
                    "parameters": [
                        {
                            "name": "NpmPackageName",
                            "title": "Npm Package Name",
                            "description": "The name of the npm package to be searched",
                            "inputType": "text",
                            "semanticDescription": "This parameter is used to identify the specific npm package to be queried. Users should provide the exact name of the npm package they want to retrieve information for as the value of this parameter."
                        }
                    ]
                }
+               ,{
+                   "id": "inquiryDeveloper",
+                   "type": "query",
+                   "title": "Developer",
+                   "description": "Developer profile inquiry",
+                   "initialRun": true,
+                   "fetchTask": false,
+                   "context": [
+                       "compose",
+                       "commandBox"
+                   ],
+                   "parameters": [
+                       {
+                           "name": "Name",
+                           "description": "Developer name",
+                           "title": "Name",
+                           "inputType": "text"
+                       }
+                   ]
+               }
            ]
         }
     ],
```

次に searchApp.ts に追加したコマンドをハンドリングするコードを追加します。
```diff:searchApp.ts (抜粋)
public async handleTeamsMessagingExtensionQuery(
    context: TurnContext,
    query: MessagingExtensionQuery
  ): Promise<MessagingExtensionResponse> {
    // console.log(context);
    console.log(query);

+    const attachments = [];
+    if (query.commandId === "findNpmPackage") {
      const searchQuery = query.parameters[0].value;
      const response = await axios.get(
        `http://registry.npmjs.com/-/v1/search?${querystring.stringify({
          text: searchQuery,
          size: 8,
        })}`
      );

      response.data.objects.forEach((obj) => {
        const template = new ACData.Template(helloWorldCard);
        const card = template.expand({
          $root: {
            name: obj.package.name,
            description: obj.package.description,
          },
        });
        const preview = CardFactory.heroCard(obj.package.name);
        const attachment = { ...CardFactory.adaptiveCard(card), preview };
        attachments.push(attachment);
      });
    }
+   else if (query.commandId === "inquiryDeveloper") {      
+     const template = new ACData.Template(helloWorldCard);
+     const card = template.expand({
+       $root: {
+         name: "QiitaWriting Developer",
+         description: "Hello, I'm the developer of this plugin.",
+       },
+     });                  
+     const preview = CardFactory.heroCard("QiitaWriting Developer");
+     const attachment = { ...CardFactory.adaptiveCard(card), preview };
+     attachments.push(attachment);
+   }

    return {
      composeExtension: {
        type: "result",
        attachmentLayout: "list",
        attachments: attachments,
      },
    };
  }
}
```
manifest.json に追加したコマンド定義に沿って処理を分岐させる実装ですね、返却する開発者情報は一旦ハードコーディングにしています。

## デバッグの実行
それではデバック実行してみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620633/c42215e6-38f7-de22-f5d6-9766e969c7a1.png)
いきなり画面変わって manifest.json の title が表示されるようになりました。
Developer を選択、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620633/6cb5916e-17c2-53d5-df26-0d48441355da.png)
そして QiitaWriting Developer を選択すると、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620633/e2d20675-fe3b-8416-7fb7-76f87b226859.png)
こんな感じにアダプティブカードが表示されます。

## Copilot からの実行
さてここまではあくまで Teams メッセージ拡張の動作、本稿の最後に Copilot プラグイン足る動作を試してみましょう。
Teams の Copilot を開いて「QiitaWritingLocal あなたの開発者情報を教えてください。」とプロンプトを投入してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620633/de52ce60-3ce3-3432-73bc-9c91fb9789ca.png)
はいこんな感じです、Copilot からプラグインが呼び出されアダプティブカードの中身を Copilot が要約してくれてますね。
Copilot の場合、Teams メッセージ拡張でプラグインを起動するのにひと手間あるところをプロンプトで賄うことができるのと言うのが特徴になります。
近い将来には自然言語で業務指示を行うと Copilot がプラグイン連携して処理を実行、なんて世界観も想像できそうですね。(いわゆる AI エージェント)

## おわりに
Microsoft 365 Copilot プラグインいかがでしたでしょうか？
本稿はもともとライセンスを使い倒そうという意図から始めたのですが、触ってみると Copilot のパラメータ解析の動作などが面白くて結構手間をかけてしまいました。
ただ生成 AI ならではの紛れみたいな動作もやっぱりあり、意図した通りにプラグインが呼び出されないと言うことがかなり起こります。ので本稿の内容は再現性の高いものに絞っています。
あとは Teams のサインインユーザー情報を取って SSO できるようなったら格好良いと思います、ここはまだ調査中ですが苦戦してます。

本稿を読んでいただきありがとうございました！