Bot FrameworkやAzure, Microsoft Teamsの初心者向けに、チュートリアルよりも一歩進んだボット作成のため必要な情報を記します。
なぜ書いたか
公式のドキュメントがいまいち分かりづらく、また近年v3からv4になったこともあり情報が少なく、概要を把握するのに時間がかかったため書きました。
説明すること
この記事では、Typescriptでの実装サンプルをベースに、Bot Frameworkを使ったボット開発に必要なコア要素の説明をします。
具体的には、以下のDialog関連の内容について説明します。
- 最小構成のbotサンプル
- Activity, TurnContextの理解
- Stateの理解
- Dialogの理解
- Dialog Set
- Prompt
- Waterfall
- Component
- Proactive Message(能動的なメッセージ送信)
- 認証について
この記事では最低限の理解のための説明を行うので、詳細な説明については、公式ドキュメントや @kenakamu さんのMicrosoft Bot Framework v4 完全制覇 : 目次をご覧ください。
また、LUISやadaptive dialogのような発展的な内容については触れません。この記事の内容で、基本的な対話ロジックは一通り実装できるようになると思います。
Microsoft Teams用のボット開発をこの記事の最終目的としますが、記事自体にはTeams固有の情報はあまりなく、Bot Frameworkについての説明が大部分となります。
※ちなみに、Teamsで動くボットを開発する方法は、現状はこの記事の通りAzure Bot Service上でBot Frameworkを使う以外にはなさそうです。
(この後説明する通りボットサーバー自体はただのWebサーバーなので、Teams上でエンドポイントを指定することで任意の技術スタックでの開発ができますが、諸々の処理を行うためには結局Bot Frameworkを使うことになります)
まずは動かしてみる
とりあえず動かすところまでは、公式のチュートリアルに説明を譲ります。
そもそもAzureに全く親しみがない人には、Azureの基本も一緒に解説してくれているちょまどさんのQiita/Youtubeがおすすめです。
エコーBot+α程度の機能のボットの作成であれば、上記をベースに改修するだけで十分でしょう。
この記事では、豊富な対話ロジックをもったボットを開発するために、より進んだ理解を得ることを目的とします。
[補足] Teamsアプリの削除方法
Zipアップロードしたアプリの削除は、 アプリ > すべて
のページからは行えず、 アプリ > すべて > すべて表示
までいってからカード右上のボタンからできます。(分かりづらかった)
最小構成で動かしてみる
チュートリアルでの操作の意味をより深く理解するため、更にミニマムな構成を作ってみます。
そもそもボットがどう動いているかというと、ボットサーバーは単純なWebサーバーであり、
このWebサーバーがBot Framework Service(Azure Bot Serviceを構成するコンポーネントの1つ)とPOSTリクエストを送りあってメッセージの送受信を行います。
図の通りですが、ボットサーバーのHTTPレスポンスにメッセージが含まれるわけではなく、ユーザーからPOSTリクエストがきたら、ボットサーバーからもPOSTリクエストを投げるような形で対話が進みます。
※公式ドキュメント ボットのしくみより引用
Azure Bot Serviceは、Microsoft Teamsを始めとした様々なチャンネルとのやり取りを抽象化してくれます。
※公式ページより引用
チュートリアルではAzure Bot Serviceの作成以外に、WebサーバーとしてAzure App Service(=Google App EngineのようなPaaS)の作成を行なっていました。
ボット用Webサーバーの役割をしっかり理解するため、まずは最小構成のWebサーバーを自分で作り直してリリースしてみましょう。
以下のようなコードが最小構成となります。
import * as restify from 'restify';
import { BotFrameworkAdapter } from 'botbuilder';
const server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, () => {
console.log(`\n${server.name} listening to ${server.url}`);
});
const adapter = new BotFrameworkAdapter({
appId: process.env.MicrosoftAppId,
appPassword: process.env.MicrosoftAppPassword
});
server.post('/api/messages', (req, res) => {
adapter.processActivity(req, res, async (context) => {
await context.sendActivity("Welcome!"); // 何を言われても、とにかくWelcomeと返す
});
});
これをリリースした状態で、Azure Portalにて、[作成したWebアプリボット] > 設定 > メッセージング エンドポイント
に https://sample-bot-pjt.appspot.com/api/messages
のようなエンドポイントを指定すれば、 Web チャットでテスト
で動作確認を行うことができます。
WebサーバーであればAzure App Service以外でも構いません。上記サンプルでは(個人的に慣れていた)Google App Engineで立てて動作確認しています。
Activity, TurnContextの理解
上記コードの中身を見てみましょう。
Bot Framework Serviceからの全てのリクエストは POST /api/messages
エンドポイントへと送られます。
リクエストが送られるタイミングは、あらゆる「Activity」が発生したタイミングです。
Activityの種類としては、「ユーザーがメッセージを送る」以外にも「誰かが会話に参加する」「メッセージにリアクションする」などがあります。
ユーザーからのあるActivityに対して、Botからは複数回のActivityを返すことができます。これらの一連の流れを「Turn」と呼びます。
adapter.processActivity()
メソッドは、HTTPリクエストの中身をパーズしてactivityを取得し、Turnの処理に必要な情報を TurnContext
というオブジェクトにまとめてcallbackメソッドに渡します。
ボットからユーザーにActivityを返すには、上記のサンプルのようにTurnContextに対して sendActivity()
を実行します。
ActivityHandlerを使う
先ほど説明した通り、あらゆるActivityが発生したタイミングで POST /api/messages
エンドポイントにリクエストが送られるため、
先ほどのサンプルだとメッセージ以外のActivityに対してもメッセージを送ってしまいます。
Activityの種類に応じて処理を書き分けたいですね。
これを行うのが、名前の通りですが ActivityHandler
クラスです。
このクラスを拡張したものがボットの本体となるため、これを Bot
クラスと命名することが多いようです。
import { ActivityHandler } from "botbuilder";
export class Bot extends ActivityHandler {
constructor() {
super();
this.onMessage(async (context, next) => {
await context.sendActivity(`受信したメッセージ: '${ context.activity.text }'`);
await next();
});
this.onConversationUpdate(async (context, next) => {
await context.sendActivity('[conversationUpdate event detected]');
await next();
});
}
}
呼び出す際は、 run()
メソッドに context
を渡します。
const bot = new Bot();
server.post("/api/messages", (req, res) => {
adapter.processActivity(req, res, async (context) => {
await bot.run(context);
});
});
Stateを使う
ここまででステートレスな(=ユーザーの発言にそのまま返信するような)ボット開発は可能かと思いますが、
リッチなボットを開発するためには、ステートフルな(=ユーザーの今までの発言を記憶して対話するような)手法が必要となるでしょう。
これを実現するには、例えばユーザーのIDごとに情報を永続化しておけば良さそうです。
Bot Frameworkではこのような永続化を簡単に行えるライブラリを提供しています。
雰囲気を理解するため、例をみてみましょう。
import { MemoryStorage, UserState } from "botbuilder";
const memoryStorage = new MemoryStorage(); // 永続化のためのストレージを作成(開発用にインメモリを使用)
const userState = new UserState(memoryStorage); // {ChannelId}/users/{UserId} のような名前空間へのアクセサを定義
const userProfileState = userState.createProperty("UserProfile"); // {ChannelId}/users/{UserId}#UserProfile のような名前空間へのアクセサを定義
const userProfile = userProfileState.get(turnContext); // ストレージのうちの上記で定義した名前空間へアクセスし、値を取り出す
userProfile.name = "Hoge"
userProfileState.set(turnContext, userProfile) // ストレージのうちの上記で定義した名前空間へアクセスし、値を格納
ストレージとしてはインメモリ(開発用)かAzure Blob Storageの2つが、
Stateとしては UserState
, ConversationState
, PrivateConversationState
の3つが用意されています。
詳細は以下のサンプル、また公式ドキュメントをご覧ください。
Stateはこの後紹介するDialogという仕組みを支える重要な概念です。
Dialogを使用する
Bot Frameworkでは複雑な対話を簡単に実装するため、Dialogと呼ばれる便利なライブラリが提供されています。Bot Frameworkのコアな要素の1つと呼べるでしょう。
様々な要素があるため、1つ1つ要素を分解して見ていきます。
Dialogの種類
用途に合わせた様々の種類のDialogが用意されています。(以下公式より引用)
この記事では、Prompt, Waterfall, Componentについて取り上げます。
PromptとDialogSet
Prompt
ユーザー入力を受け取るだけの対話です。
シンプルな要素ですが、選択肢による入力、日時による入力など種類があり、これだけでもだいぶ便利です。
DialogSet
複数の対話要素、またユーザーの対話状態を管理する概念です。
Promptなどの対話要素をDialogSetに登録した上で createContext
することで、ユーザーの対話状態を復元することができます。
const ASK_NAME_DIALOG_ID = "ASK_NAME";
export class Bot extends TeamsActivityHandler {
conversationState: any;
constructor() {
super();
const memoryStorage = new MemoryStorage();
this.conversationState = new ConversationState(memoryStorage);
const dialogState = this.conversationState.createProperty("DialogState"); // dialog用のstateを作成
const dialogSet = new DialogSet(dialogState); // stateを元に、dialogSetを生成
dialogSet.add(new TextPrompt(ASK_NAME_DIALOG_ID)); // 使用したいdialog(ここではprompt)を追加する
this.onMessage(async (context, next) => {
const dialogContext = await dialogSet.createContext(context as any); // ユーザーの対話の状態を復元
console.log(dialogContext.stack); // ※1
// 既に対話が開始されていれば、対話の処理を続ける
// (この例ではTextPromptの処理、すなわちユーザーのテキスト入力を受け付ける処理)
const results = await dialogContext.continueDialog();
if (results.status === DialogTurnStatus.empty) { // 対話が開始されていなければ、emptyステータスとなる
await context.sendActivity(`名前を入力してね`);
await dialogContext.beginDialog(ASK_NAME_DIALOG_ID); // 対話のIDを指定し、対話を開始する
// await dialogContext.prompt(ASK_NAME, "名前を入力してね"); // Syntax Sugar
} else if (results.status === DialogTurnStatus.complete) { // 対話が完了していれば、completeステータスとなる(この例だと、promptが入力を受け付けたら完了)
await context.sendActivity(`ようこそ、 ${results.result}さん`); // promptで受け付けたユーザー入力はresults.resultに格納される
}
await next();
});
}
async run(context) {
await super.run(context);
await this.conversationState.saveChanges(context, false); // beginDialog, continueDialogにより更新されたdialogStateを保存
}
}
対話スタックの確認
対話にはスタックという概念があります。詳細な説明は公式に譲るとして、、ここでは※1の console.log(dialogContext.stack)
のように、対話スタックの中身を出力してみましょう。ここでのstackの出力は以下のようになります。
- 対話が始まっていないときは空の配列
[]
- 「名前を入力してね」と発言した後に、ユーザーからメッセージを受け取った時点のstackは以下
[ { id: 'ASK_NAME', state: { options: {}, state: {} } } ]
- 入力を終え、対話が終了したときは再び空の配列
[]
promptを開始することで対話スタックが積まれ、終了されるとスタックが空になることがわかります。
TeamsでChoicePromptを使う際の注意
Promptに限りませんが、Bot Frameworkが用意してくれている各種の要素が実際にどう表示されるかは、使用するチャネルに依存することに注意しましょう。
例えば、選択肢を表示してくれる ChoicePrompt
は非常に便利なのですが、Microsoft Teams上では選択肢の数が4つ以上になると、ただのテキスト表示になります。
選択肢の数が増えても、Teamsで以下のようなボタンを表示するためには、リッチカードを使う必要がありました。
Waterfall
promptのような小さな対話要素を繋げることで、
- はじめに、目的地を聞く
- その次に、日時を聞く
- 最後に、確認する
のような一連の対話の流れを実装するための要素です。
以下に「名前を聞く」「名前を確認する」「最後にメッセージを送る」というシンプルなwaterflowで構成されるサンプルコードを示します。
// 略
const askNameStep: WaterfallStep = async (step) => {
return await step.prompt(ASK_NAME_DIALOG_ID, "名前を入力してね");
};
const confirmNameStep: WaterfallStep = async (step) => {
step.values.name = step.result; // waterfallの対話中、step.valuesの値は保存される
return await step.prompt(
CONFIRM_NAME_DIALOG_ID,
`${step.values.name}さんで宜しいですか?`
);
};
const completeStep: WaterfallStep = async (step) => {
// 略
};
export class Bot extends TeamsActivityHandler {
constructor() {
// 略
// Waterfall dialogで使うdialogも、同じdialogSetから呼ばれるため、dialogSetへの追加が必要
dialogSet.add(new TextPrompt(ASK_NAME_DIALOG_ID));
dialogSet.add(new ConfirmPrompt(CONFIRM_NAME_DIALOG_ID));
// Waterfall dialogを追加
dialogSet.add(
new WaterfallDialog(WATERFALL_DIALOG_ID, [
askNameStep,
confirmNameStep,
completeStep,
])
);
this.onMessage(async (context, next) => {
const dialogContext = await dialogSet.createContext(context as any);
console.log(dialogContext.stack); // ※1
// stackがあれば、stackの最後の対話を継続する。
// 「名前を入力してね」の後であれば、ASK_NAME_DIALOG_IDの処理を完了させ、その結果をstep.resultに格納し、
// 次のstepであるconfirmNameStepを実行し、CONFIRM_NAME_DIALOG_IDの対話を開始する。
const results = await dialogContext.continueDialog();
// stackがなければ、WATERFALL_DIALOG_IDの対話を開始する。
// waterfall dialogが開始されると、最初のstepにあるpromptが対話スタックに積まれる。
if (results.status === DialogTurnStatus.empty) {
await dialogContext.beginDialog(WATERFALL_DIALOG_ID);
}
await next();
});
}
// 略
}
対話スタックの確認
ここでも対話スタックの中身を出力してみましょう。ここでのstackの出力は以下のようになります。
- 対話が始まっていないときは空の配列
[]
- 「名前を入力してね」と発言した後に、ユーザーからメッセージを受け取った時点のstackは以下
- waterfall dialogの上に、更にprompt dialogが積まれている
- waterfallのstateで、valuesをもっている
[
{
id: 'WATERFALL_DIALOG_ID',
state: { options: {}, values: [Object], stepIndex: 0 }
},
{ id: 'ASK_NAME_DIALOG_ID', state: { options: [Object], state: {} } }
]
waterfallの内部実装的には、上記の stepIndex
で何ステップ目かを管理しているため、これを操作するとステップを戻すことが可能です。
※軽く調べたところ、他に方法はなさそうでしたが、バッドノウハウだと思うのでご利用にはご注意ください。
const confirmNameStep: WaterfallStep = async (step: WaterfallStepContext) => {
step.stack[0].state.stepIndex = 0; // この次に開始されるステップを2ステップ目にする
}
Component
複数の対話シナリオからなる大規模なボットを、モノリシックな実装ではなく、DialogSetを名前空間ごとに分けて適切な粒度で実装するための対話要素がComponentになります。
この辺はWebサービスの開発と同じですね。
実装は、 ComponentDialog
クラスを拡張する形で行います。
export class MainDialog extends ComponentDialog {
constructor(userState: UserState) {
super(COMPONENT_DIALOG_ID); // このcomponent dialogのIDを指定
// dialogSet.add() の代わりに、this.addDialog()でdialogを登録する
this.addDialog(new TextPrompt(ASK_NAME_DIALOG_ID));
this.addDialog(new ConfirmPrompt(CONFIRM_NAME_DIALOG_ID));
this.addDialog(
new WaterfallDialog(WATERFALL_DIALOG_ID, [
this.nameStep.bind(this),
this.confirmStep.bind(this),
this.summaryStep.bind(this),
])
);
// このcomponent dialogが開始された時に開始する対話のIDを、addDialogしたものの中から指定
this.initialDialogId = WATERFALL_DIALOG_ID;
}
async nameStep(step) {
return await step.prompt(ASK_NAME_DIALOG_ID, "名前を入力してね");
}
async confirmStep(step) {
// 略
}
}
見た目はwaterfall, promptといったdialogと異なりますが、componentも単なる対話要素の1つであり、dialogSetにaddして対話を開始する流れは変わりません。
ここまでは ActivityHandler
を拡張したBotクラスの中で DialogSet
関連の一連の処理を行なっていましたが、再利用性を高めるため、この流れもクラスのメソッドとして定義しましょう。
export class MainDialog extends ComponentDialog {
async run(turnContext: TurnContext, dialogState: StatePropertyAccessor<DialogState>) {
const dialogSet = new DialogSet(dialogState as any);
dialogSet.add(this);
const dialogContext = await dialogSet.createContext(turnContext as any);
console.log(dialogContext.stack); // ※1
const results = await dialogContext.continueDialog();
if (results.status === DialogTurnStatus.empty) {
await dialogContext.beginDialog(this.id);
}
}
}
Botクラス側の記述は、インスタンスを作成して上記の run
メソッドを呼ぶだけになります。
export class Bot extends TeamsActivityHandler {
constructor() {
// 略
const dialog = new MainDialog(this.userState);
const dialogState = this.conversationState.createProperty("DialogState");
this.onMessage(async (context, next) => {
await dialog.run(context, dialogState);
await next();
});
}
対話スタックの確認
※1 の箇所で対話スタックを出力すると以下のようになります。
[
{
id: 'COMPONENT_DIALOG_ID',
state: { dialogs: [Object] },
version: '-1104749189'
}
]
この出力は、waterfallで定義した対話が完了するまで変わりません。
すなわち、ルートレベルのスタックとしては、 dialogSet.add()
したcomponent dialogの対話が積まれているだけで、 waterfallの対話の処理はcomponent dialogの中のDialogSetで処理が隠蔽されることがわかります。
dialog componentのスタックが積まれている際、 dialogContext.stack[0].state.dialogs
を出力すると、ここに更に対話のスタックが積まれていることが確認できます。
{
dialogStack: [
{ id: 'WATERFALL_DIALOG_ID', state: [Object] },
{ id: 'ASK_NAME_DIALOG_ID', state: [Object] }
]
}
Proactive Message
ここまででBotの基本的な対話要素については説明しました。
最後に、Botから能動的にメッセージを送るパターンについて説明します。
Botから能動的にメッセージを送りたい場合、事前にActivityを元にConversationReferenceというオブジェクトを取得しておき、これを使ってメッセージを送ることになります。
// ActivitiyHandler
const conversationReference = TurnContext.getConversationReference(activity);
// indes.ts(server)
server.get("/api/notify", async (req, res) => {
await adapter.continueConversation(
conversationReference,
async (turnContext) => {
await turnContext.sendActivity("Proactive Message");
}
);
}
さて、ここまでは公式でも説明している内容ですが、公式ではProactive MessageでDialogを扱う方法について特に言及がありません。個人的に苦労した点なので、少し触れておきます。
Proactive Messageを使って強制的にDialogを開始させたいとき、例えば毎朝9時に「今日の目標は何ですか?」のように入力を促したい時、Proactive Messageで使う対話スタックと、ユーザーの返信の際に使う対話スタックが一致する必要があるため、同じ対話コンポーネントを使いつつProactiveのときだけ対話を割り込むような処理が必要になります。
これを実現するため、自分はcontextを拡張する形で実装を行いました。
// index.ts
server.post("/api/notify/reminder", async (req, res) => {
const conversationReference = req.body; // conversation referenceは外部で永続化しておき、リクエストのbodyとして受け取る
await adapter.continueConversation(conversationReference, async (turnContext: ExtendedTurnContext) => {
turnContext.proactiveMessageType = "remind"; // turnContextを拡張
await bot.run(turnContext); // /api/messages で使うbotインスタンスと同じ
});
res.end();
});
// dialog.ts
async run(turnContext: ExtendedTurnContext, accessor) {
const dialogSet = new DialogSet(accessor);
dialogSet.add(this);
const dialogContext = await dialogSet.createContext(turnContext as any);
if (turnContext.proactiveMessageType === 'remind') {
// proactive messageであれば、対話を割り込み処理する
await dialogContext.replaceDialog(this.id);
} else {
// そうでなければ通常通りの対話の処理を行う
const results = await dialogContext.continueDialog();
if (results.status === DialogTurnStatus.empty) {
await dialogContext.beginDialog(this.id);
}
}
}
認証
長くなってきたので説明を省略しますが、Microsoftアカウントなどを使ったOAuth認証も、promptでサポートされています。
認証が成功すると step.result
にJWTが格納されるので、これを使って処理を行うと良いでしょう。