Bot Builder v4 : Core テンプレートを解析してみる

こちらの記事では、Yeoman の BotBuilder テンプレートにて作成される Core テンプレ―トを解析してみます。Node.js を使ったボットの始め方は、[テンプレートを使って Node.js でボットを作る](https://qiita.com/kenakamu/items/9c3a0448a27f171bf972) を見てください。

TypeScript 版で見ていきますが、基本的な概念や要素は JavaScript 版でも同じです。また記事執筆時点でのバージョンは 4.4 です。

# Core テンプレートが提供する機能

[README.md](https://github.com/microsoft/BotBuilder-Samples/blob/master/generators/generator-botbuilder/generators/app/templates/core/README.md.ts) では以下の機能があると説明があります。

- LUIS を使った AI 機能
- ダイアログ機能を使ったマルチターン会話
- ヘルプやキャンセルというキーワードに反応する割込み処理
- ユーザー入力に対する検証処理

# 起動する仕組み

ボットは通常の Node.js アプリケーションと同じように動作します。

package.json を見ると以下の様に index.js がエントリポイントとなります。

```
"start": "tsc --build && node ./lib/index.js",
```

TypeScript で書かれた index.ts を見ると、restfy パッケージを使ってポート 3987 の /api/messages アドレスで POST メッセージを待っていることが分かります。

```typescript:index.ts
// Create HTTP server
const server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, () => {
    console.log(`\n${ server.name } listening to ${ server.url }`);
    console.log(`\nGet Bot Framework Emulator: https://aka.ms/botframework-emulator`);
});

// Listen for incoming activities and route them to your bot main dialog.
server.post('/api/messages', (req, res) => {
    // Route received a request to adapter for processing
    adapter.processActivity(req, res, async (turnContext) => {
        // route to bot activity handler.
        await bot.run(turnContext);
    });
});
```
ここに出てくる adapter はボットのコードとチャットクライアントを接続する役目を持っていて、以下のコードで作成されます。アダプターは Facebook Messenger や LINE など異なるプラットフォームのデータ形式を BotBuilder で使う形式に変換したり、返信時に元に戻すなどの役割があります。

```typescript
const adapter = new BotFrameworkAdapter({
    appId: process.env.MicrosoftAppID,
    appPassword: process.env.MicrosoftAppPassword,
});
```

メッセージを受け取ると作成済の bot クラスの run 関数を実行しています。bot は以下のコードでインスタンス化しています。

```typescript:index.ts
let conversationState: ConversationState;
let userState: UserState;
const memoryStorage = new MemoryStorage();
conversationState = new ConversationState(memoryStorage);
userState = new UserState(memoryStorage);

const logger = console;
const dialog = new MainDialog(logger);
const bot = new DialogAndWelcomeBot(conversationState, userState, dialog, logger);
```

それぞれのコンポーネントの詳細はおいおい説明します。では次に提供される機能を説明します。

# LUIS を使った AI 機能 (自然言語処理)

Core ボットは LUIS と連携するようコードが生成されています。既定では LUIS アプリケーション側がありませんが、以下記事で LUIS のセットアップ手順と概念が説明されています。

[ボットに自然言語の理解を追加する](https://docs.microsoft.com/ja-jp/azure/bot-service/bot-builder-howto-v4-luis?view=azure-bot-service-4.0&tabs=javascript)

LUIS を使うことで、ユーザーが送ってきた文章の意図(インテント) やキーワード(エンティティ) を取得することが出来ます。
※サンプルのデータは英語版ですが、興味があれば試してみてください。詳細はまた別の機会に紹介します。

# ダイアログ機能を使ったマルチターン会話

ユーザーと複数のやり取りをするボットを開発する場合、ユーザーのインプットを記憶したり、次に発話する内容を制御したりする必要があります。

- ターン: ユーザーとボットの 1 会話
- ダイアログ: 複数ターンを 1 つの塊として捉えたもの

[参照: ユーザーおよび会話データを保存する](https://docs.microsoft.com/ja-jp/azure/bot-service/bot-builder-howto-v4-state?view=azure-bot-service-4.0&tabs=javascript)

## コンテキストの保存

会話のコンテキストなど一時データを保存する仕組みとして、BotBuilder は以下を提供します。

- [Storage](https://github.com/microsoft/botbuilder-js/blob/4.4/libraries/botbuilder-core/src/storage.ts) : データを保存する先。開発時点で利用できるメモリ上のストレージをはじめ、Azure Storage などをサポート。
- [BotState](https://github.com/microsoft/botbuilder-js/blob/4.4/libraries/botbuilder-core/src/botState.ts) : データを保持するクラス。ここから派生する ConversationState と UserState があり、会話単位の記録とユーザーレベルの記録を分けて保持できる。

テンプレートから作成したボットの index.ts にコードがあります。Azure ストレージや Cosmos DB などを使う場合は以下コードに続くコメントを参照してください。

```typescript:index.ts
// Define a state store for your bot. See https://aka.ms/about-bot-state to learn more about using MemoryStorage.
// A bot requires a state store to persist the dialog and user state between messages.
let conversationState: ConversationState;
let userState: UserState;

// For local development, in-memory storage is used.
// CAUTION: The Memory Storage used here is for local bot debugging only. When the bot
// is restarted, anything stored in memory will be gone.
const memoryStorage = new MemoryStorage();
conversationState = new ConversationState(memoryStorage);
userState = new UserState(memoryStorage);
```

## ダイアログの開発
複数のターンを含むダイアログは、BotBuilder SDK のダイアログシステムを使います。

[参照: 連続して行われる会話フローの実装](https://docs.microsoft.com/ja-jp/azure/bot-service/bot-builder-dialog-manage-conversation-flow?view=azure-bot-service-4.0&tabs=javascript)

ダイアログ開発では以下の要素を使います。

### ダイアログコンテキスト

現在のダイアログ実行状況が確認できるコンテキスト。実行状況に応じて任意のダイアログを呼び出したり、現在実行中のダイアログを継続して実行するなどの処理をします。

```typescript
// ステート管理で管理されているダイアログステートを渡してダイアログセットを作成
const dialogSet = new DialogSet(dialogState);
dialogSet.add(this);

// ダイアログセットから現在のダイアログコンテキストを作成
const dialogContext = await dialogSet.createContext(context);
// 既存のダイアログがあれば実行
const results = await dialogContext.continueDialog();
// 実行結果によって処理を分岐。Empty の場合はダイアログが実行されていないため、
// 任意のダイアログを呼び出し
if (results.status === DialogTurnStatus.empty) {
    await dialogContext.beginDialog(this.id);
}
```

### プロンプト

ユーザーに対してメッセージを送信し、入力を促します。期待する入力値によって使うプロンプトが存在し、それぞれ入力の検証機能が標準で備わっています。尚、プロンプトもダイアログの一形態とみなされます。

- Prompts.text: テキスト文字列を入力することをユーザーに求めます。
- Prompts.confirm: アクションを確認することをユーザーに求めます。
- Prompts.number: 数値を入力することをユーザーに求めます。
- Prompts.time: 時間または日付/時刻を入力することをユーザーに求めます。
- Prompts.choice: オプションの一覧から選択することをユーザーに求めます。
- Prompts.attachment: ピクチャまたはビデオをアップロードすることをユーザーに求めます。

### ウォーターフォールダイアログ

プロンプトを実行する関数を順番に実行するためのダイアログです。
ウォーターフローダイアログに渡す関数は DialogTurnResult を返すようにします。

#### ステップコンテキスト (stepContext)

ウォーターフォールダイアログで順次実行される関数には、コンテキスト情報として stepContext が渡されます。stepContext では以下のようなことが出来ます。

skipContext.prompt: ユーザーにプロンプトを返す
skipContext.next: 次の関数に移動する
skipContext.end: ウォーターフローダイアログを終了する


```typescript
private async destinationStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {
    const bookingDetails = stepContext.options as BookingDetails;

    if (!bookingDetails.destination) {
        // テキストプロンプトを実行
        return await stepContext.prompt(TEXT_PROMPT, { prompt: 'To what city would you like to travel?' });
    } else {
        // 次のステップへ移行
        return await stepContext.next(bookingDetails.destination);
    }
}
```

### コンポーネントダイアログ

ダイアログ管理を簡単にするための基底クラスです。ダイアログを追加したり、初期実行するダイアログを指定する機能があります。

### ダイアログの実装

テンプレートから生成されたコードには、複数のダイアログが含まれますが、ここでは BookingDialog を見ていきます。

1\. コード上部で必要なモジュールのインポートを実施。

```typescript:bookingDialog.ts
import { TimexProperty } from '@microsoft/recognizers-text-data-types-timex-expression';
import {
    ConfirmPrompt,
    DialogTurnResult,
    TextPrompt,
    WaterfallDialog,
    WaterfallStepContext,
} from 'botbuilder-dialogs';
import { BookingDetails } from './bookingDetails';
import { CancelAndHelpDialog } from './cancelAndHelpDialog';
import { DateResolverDialog } from './dateResolverDialog';
```

2\. プロンプトは名前で呼び出すため、各種名前を定数として指定。

```typescript:bookingDialog.ts
const CONFIRM_PROMPT = 'confirmPrompt';
const DATE_RESOLVER_DIALOG = 'dateResolverDialog';
const TEXT_PROMPT = 'textPrompt';
const WATERFALL_DIALOG = 'waterfallDialog';
```

3\. クラス定義で ComponentDialog を継承する CancelAndHelpDialog を継承。
※CancelAndHelpDialog については後で詳細を説明。

```typescript:bookingDialog.ts
export class BookingDialog extends CancelAndHelpDialog {
    constructor(id: string) {
        super(id || 'bookingDialog');
```

4\. コンポーネントダイアログの機能を使って、利用するダイアログ(プロンプト含む)を追加。

- テキストプロンプト、確認プロンプトを追加。
- カスタムダイアログである DateResolverDialog を追加。
- 処理順に関数を指定したウォーターフローダイアログを追加。

```typescript:bookingDialog.ts
this.addDialog(new TextPrompt(TEXT_PROMPT))
        .addDialog(new ConfirmPrompt(CONFIRM_PROMPT))
        .addDialog(new DateResolverDialog(DATE_RESOLVER_DIALOG))
        .addDialog(new WaterfallDialog(WATERFALL_DIALOG, [
            this.destinationStep.bind(this),
            this.originStep.bind(this),
            this.travelDateStep.bind(this),
            this.confirmStep.bind(this),
            this.finalStep.bind(this),
        ]));

    this.initialDialogId = WATERFALL_DIALOG;
```

5\. DialogResult を返すようにそれぞれの関数を追加。まず行先を聞く関数を追加。既に行先が取れている場合は次の関数にスキップ。

```typescript:bookingDialog.ts
private async destinationStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {
    const bookingDetails = stepContext.options as BookingDetails;

    if (!bookingDetails.destination) {
        return await stepContext.prompt(TEXT_PROMPT, { prompt: 'To what city would you like to travel?' });
    } else {
        return await stepContext.next(bookingDetails.destination);
    }
}
```

6\. 次に出発元を聞く関数を追加。こちらも既に出発元が分かっている場合はスキップ。

```typescript:bookingDialog.ts
private async originStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {
    const bookingDetails = stepContext.options as BookingDetails;

    // Capture the response to the previous step's prompt
    bookingDetails.destination = stepContext.result;
    if (!bookingDetails.origin) {
        return await stepContext.prompt(TEXT_PROMPT, { prompt: 'From what city will you be travelling?' });
    } else {
        return await stepContext.next(bookingDetails.origin);
    }
}
```
7\. 旅行の日付を聞く関数を追加。同時に実装した日付を解決するダイアログを利用。また入力された日付が不明寮か確認する関数を活用して日付を確定。

```typescript:bookingDialog.ts
private async travelDateStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {
    const bookingDetails = stepContext.options as BookingDetails;

    // Capture the results of the previous step
    bookingDetails.origin = stepContext.result;
    if (!bookingDetails.travelDate || this.isAmbiguous(bookingDetails.travelDate)) {
        return await stepContext.beginDialog(DATE_RESOLVER_DIALOG, { date: bookingDetails.travelDate });
    } else {
        return await stepContext.next(bookingDetails.travelDate);
    }
}
```

8\. 入力が間違いないか確認する関数を追加。

```typescript:bookingDialog.ts
private async confirmStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {
    const bookingDetails = stepContext.options as BookingDetails;

    // Capture the results of the previous step
    bookingDetails.travelDate = stepContext.result;
    const msg = `Please confirm, I have you traveling to: ${ bookingDetails.destination } from: ${ bookingDetails.origin } on: ${ bookingDetails.travelDate }.`;

    // Offer a YES/NO prompt.
    return await stepContext.prompt(CONFIRM_PROMPT, { prompt: msg });
}
```

9\. 最後にダイアログ終了の処理を追加。

```typescript:bookingDialog.ts
private async finalStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {
    if (stepContext.result === true) {
        const bookingDetails = stepContext.options as BookingDetails;

        return await stepContext.endDialog(bookingDetails);
    } else {
        return await stepContext.endDialog();
    }
}
```

### テンプレートでのダイアログの実行

テンプレートでは mainDialog.ts の run 関数に自分自身を実行するコードがあります。

```typescript:mainDialog.ts
public async run(context: TurnContext, accessor: StatePropertyAccessor<DialogState>) {
    const dialogSet = new DialogSet(accessor);
    dialogSet.add(this);

    const dialogContext = await dialogSet.createContext(context);
    const results = await dialogContext.continueDialog();
    if (results.status === DialogTurnStatus.empty) {
        await dialogContext.beginDialog(this.id);
    }
}
```

このコードが実行されることで mainDialog のウォーターフォールダイアログが実行されます。

```typescript:mainDialog.ts
.addDialog(new WaterfallDialog(MAIN_WATERFALL_DIALOG, [
    this.introStep.bind(this),
    this.actStep.bind(this),
    this.finalStep.bind(this),
]));
```

introStep で LUIS を構成しているか確認し、ユーザーに対してできることを説明しています。

```typescript:mainDialog.ts
private async introStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {
    if (!process.env.LuisAppId || !process.env.LuisAPIKey || !process.env.LuisAPIHostName) {
        await stepContext.context.sendActivity('NOTE: LUIS is not configured. To enable all capabilities, add `LuisAppId`, `LuisAPIKey` and `LuisAPIHostName` to the .env file.');
        return await stepContext.next();
    }

    return await stepContext.prompt('TextPrompt', { prompt: 'What can I help you with today?\nSay something like "Book a flight from Paris to Berlin on March 22, 2020"' });
}
```
actStep では LUIS がある場合は解析結果から行先などの初期値を作成し、bookingDialog を呼び出しています。

```typescript:mainDialog.ts
return await stepContext.beginDialog('bookingDialog', bookingDetails);
``` 
finalStep で bookingDialog より戻った値を使ってユーザーにメッセージを表示しています。

```typescript:mainDialog.ts
private async finalStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {
    // If the child dialog ("bookingDialog") was cancelled or the user failed to confirm, the Result here will be null.
    if (stepContext.result) {
        const result = stepContext.result as BookingDetails;
        // Now we have all the booking details.

        // This is where calls to the booking AOU service or database would go.

        // If the call to the booking service was successful tell the user.
        const timeProperty = new TimexProperty(result.travelDate);
        const travelDateMsg = timeProperty.toNaturalLanguage(new Date(Date.now()));
        const msg = `I have you booked to ${ result.destination } from ${ result.origin } on ${ travelDateMsg }.`;
        await stepContext.context.sendActivity(msg);
    } else {
        await stepContext.context.sendActivity('Thank you.');
    }
    return await stepContext.endDialog();
}
```

# 割込み処理

ユーザーが「キャンセル」や「ヘルプ」と言った特別な言葉を送ってきた場合、どのようなダイアログの途中であっても対応する必要があります。これを割込み処理を呼びます。

## 割込み処理を実施する場所

コンポーネントダイアログを継承したダイアログ実行時、以下のイベントが発生します。

- onBeginDialog
- onContinueDialog

それぞれのイベントを受けた際、実際のダイアログを処理する間に割込み処理が必要か確認して、対処します。
また割込み処理の対処をした際、現在のダイアログを中止するか継続するかを決めることが出来ます。

## CancelAndHelpDialog の実装

テンプレートから作成されたコードでは、中断処理を受け入れるダイアログは、CancelAndHelpDialog を継承しています。CancelAndHelpDialog は ComponentDialog を継承した通常のダイアログです。 

```typescript:cancelAndHelpDialog.ts
export class CancelAndHelpDialog extends ComponentDialog {
    constructor(id: string) {
        super(id);
    }
```
ダイアログ開始時と継続時に、割込みが必要か確認します。

```typescript:cancelAndHelpDialog.ts
public async onBeginDialog(innerDc: DialogContext, options: any): Promise<DialogTurnResult> {
    const result = await this.interrupt(innerDc);
    if (result) {
        return result;
    }
    return await super.onBeginDialog(innerDc, options);
}

public async onContinueDialog(innerDc: DialogContext): Promise<DialogTurnResult> {
    const result = await this.interrupt(innerDc);
    if (result) {
        return result;
    }
    return await super.onContinueDialog(innerDc);
}
```

割込み処理では、送られてきた文章によって処理を分岐します。ヘルプの場合はヘルプを表示したのち、ダイアログが継続できるよう DialogTurnStatus.waiting を返し、キャンセルの場合はダイアログコンテキストの cancelAllDialogs() メソッドを実行します。

```typescript:cancelAndHelpDialog.ts
public async interrupt(innerDc: DialogContext): Promise<DialogTurnResult|undefined> {
    const text = innerDc.context.activity.text.toLowerCase();

    switch (text) {
        case 'help':
        case '?':
            // ヘルプを表示後継続の状態を返す
            await innerDc.context.sendActivity('[ This is where to send sample help to the user... ]');
            return { status: DialogTurnStatus.waiting };
        case 'cancel':
        case 'quit':
            // 現在のダイアログを全て中断
            await innerDc.context.sendActivity('Cancelling');
            return await innerDc.cancelAllDialogs();
    }

    return;
}
```

# ユーザー入力に対する検証処理

既定でプロンプトはユーザーの入力を検証してくれます。添付を期待しているのに文字列が送られて来た場合などは、リトライの文言を自動で送ります。ただし日付型かどうかだけでなく、年月日がすべて含まれているかといった、より詳細な検証をしたい場合は、プロンプトに自分の検証関数を渡すことが出来ます。

以下の例では、日付プロンプトに対して、dateTimePromptValidator 関数を渡しています。
この場合ユーザーの入力は全て指定した関数で検証されます。

```typescript
new DateTimePrompt(DATETIME_PROMPT, this.dateTimePromptValidator.bind(this))
```

## 検証処理の実装

検証処理の関数は、結果として Boolean を返します。以下の検証関数は入力値で年月日が含まれるかを検証しています。

```typescript:dateResolverDialog.ts
private async dateTimePromptValidator(promptContext: PromptValidatorContext<DateTimeResolution>): Promise<boolean> {
    if (promptContext.recognized.succeeded) {
        // This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part.
        // TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year.
        const timex = promptContext.recognized.value[0].timex.split('T')[0];

        // If this is a definite Date including year, month and day we are good otherwise reprompt.
        // A better solution might be to let the user know what part is actually missing.
        return new TimexProperty(timex).types.has('definite');
    } else {
        return false;
    }
}
```

# まとめ

Core テンプレートは名前の通り、BotBuilder が提供するコア機能が網羅されたコードになっています。より高度なボットを開発するためには、より多くの機能が必要ですが、まずここからはじめる事をお勧めします。