おてんきからー

Flutter+Dialogflowで天気を教えてくれる簡単なチャットボットを作ろう!


■ はじめに

これは Flutter # 2 Advent Calendar 2018 12日目の記事です。

2018年もテック界隈は色々なニュースがありました。

個人的にはFlutter1.0が発表され、Kotlin1.3からKotlin/Nativeベータが導入されるされるなど、クロスプラットフォーム開発がにわかに盛り上がってきたのが気になる話題の一つでした。

情報を追ううちに段々と興味が湧いてきて、2ヶ月ほど前からmonoさんの記事を読みつつFlutterを触り始めました。

チュートリアル+Udacity(Google公式で無料)をやって、今はぼちぼちアプリを作って遊んでます。

現段階では豊富なウィジェットやFlutter独自のお作法を覚えるのにやや学習コストを感じるものの、ブロックを組み合わせる感覚でアプリを組めるのが楽しいのと、慣れればかなりコンパクトにコードを書けそうで、高い生産性が期待できそうと思っています。

さて、前置きが長くなりましたが、この記事ではFlutterとDialogflow(チャットボットAPIが簡単に作れるGoogle製サービス)でチャットボットアプリを作ります。読み手として、そこそこプログラムが書けるFlutterビギナーを想定しています。Flutterを学習する上で自分が難しく感じた点は、なるだけピンポイントで解説を入れているので、理解の助けになれば幸いです。

先に完成品を提示しておきます。

画像1

日本語でなく英語でチャットしてるのは、単純にDialogflowで言語を日本語に設定すると「地名」を英語で取れず、天気APIにクエリを投げる手間が増えるためです(´・ω・`)

さて、それではこちらを実装していきます。

■ 開発環境

・ MacOS Mojave10.14.1
・Flutter 0.11.13
・ Dart 2.1.0

※ Flutterはインストールしている前提で進めるので、まだな場合は公式サイトを参考にインストールしておいて下さい。

■ プロジェクト作成

画像2

システム構成は、FlutterクライアントとDialogflowが通信するだけの単純な構成にします。また、プロジェクトは次の手順で作成していきます。
1. 新規Flutterプロジェクトを作成し、依存パッケージをインストール
2. Dialogflowでチャットボットを作る
3. Flutterでクライアントを作る
4. 完成

□ Flutterプロジェクト作成

初めに、次のコマンドで新規Flutterプロジェクトを作成します。プロジェクトを作成したらプロジェクトルートへ移動します。

$ flutter create flutter_chatbot
$ cd flutter_chatbot

依存パッケージとしてflutter_dialogflowを使うので、pubspec.yamlでdependencies下に次を追記します。

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^0.1.2
  flutter_dialogflow: ^0.1.0

また、このプロジェクトでは追加リソースとして次を使います。
1. Dialogflow認証情報(dialogflow.json)
2. チャットボット用アイコン(bot.png)
1については後で取得から解説しますが、2に関しては適当な画像を用意してください。これらのリソースも、pubspec.yamlに追記することでアプリ内で使用可能になります。pubspec.yamlでassets部分のコメントアウトを外し、次のようにリソースを追記します。

flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
assets:
- img/bot.png
- config/dialogflow.json

これでpubspec.yamlへの追記は終了です。最後に、次のコマンドで依存パッケージをインストールしておきます。



$ flutter packages get

◼Dialogflow部分

それでは、DialogflowでチャットボットAPIを作っていきます。

Dialogflowはコードを書かず簡単にチャットボットが作れるサービスですが、今回は天気情報を外部サービス(WorldWeatherOnline)から取得するため、少しだけコードを書きます。ただ、コーディング作業もブラウザ上で完結するため、非常に楽でした。

なお、Dialogflowについて詳解は避けるので、用語や導入については次を参照してください。(とても分かりやすかったです☺️)

□エージェント新規作成

それではDialogflowへアクセスし、新規Agent(エージェント)を作成していきます。

画像3

名前など自由に設定して良いですが、DEFAULT LANGUAGEはEnglishにしてください。冒頭で述べたように、日本語を設定すると地名エンティティを英語で取得できず、天気APIへ直接クエリを投げることができません。

□ 認証情報の作成

DialogflowをFlutterから使うため、認証情報を次の手順で取得します。

1. エージェントを作成後、GoogleCloudPlatformへアクセスする
2. エージェント作成時に選択、もしくは新規作成したGOOGLE PROJECTを選択する
3. ナビゲーションメニュー>APIとサービス>認証情報を選択する
4. 認証情報を作成>サービスアカウントキーを選択する
5. サービスアカウントは「Dialogflow Integrations」、キーのタイプは「json」を選択し、作成ボタンを押す
6. <プロジェクト名xxxxxx>.json(もしくはfile.txt)というファイルがダウンロードされるので大切に保管する

□ 天気APIのAPI KEYを取得

認証情報をダウンロードできたら、次は天気情報を取得できるようにします。

無料で天気APIが使えるWorldWeatherOnlineにサインアップし、マイアカウントよりAPI KEYを取得してください。

□ 天気予報botの作成

それでは天気予報botを作成していきます。
まず、Create Intentより「weather」Intentを作成します。

画像4

Training phraseには「What is the weather in Tokyo tomorrow?」と入力し、ユーザ発話例を作成します。ここでTokyoに@sys.geo-city、tomorrowに@sys.dateエンティティが割当られなければ該当部分を選択し、対応するエンティティを設定していきます。

Action and parametersでは、geo-city、dateパラメータでREQUIREDにチェックを入れます。こうすることで、ユーザ発話に該当するエンティティが含まれない場合、抜けた部分を埋めるために聞き返してくれます。

最後にFulfillmentでEnable webhook call for this intentにチェックを入れます。これで天気APIを利用できるようになります。

SAVEを押して変更を反映します。

次にFulfillmentへ移動し、InlineEditorをEnabledにします。

画像5

下のコードをInlineEditor内にコピペし、<ENTER_WWO_API_KEY_HERE>部分へは取得したAPI KEYを貼り付けてください。最後にInlineEditor下でDEPLOYボタンを押せば、天気予報botが完成です。(コードはDialogflow公式サンプルをベースにしています)

// Copyright 2017, Google, Inc.
// Licensed under the Apache License, Version 2.0 (the 'License');
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an 'AS IS' BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

'use strict';

const http = require('http');
const functions = require('firebase-functions');

const host = 'api.worldweatheronline.com';
const wwoApiKey = '<ENTER_WWO_API_KEY_HERE>';

exports.dialogflowFirebaseFulfillment = functions.https.onRequest((req, res) => {
  // リクエストからgeo-cityとdateを取得
  let city = req.body.queryResult.parameters['geo-city']; // city is a required param

  // 天気予報の日付を取得 (存在する場合)
  let date = '';
  if (req.body.queryResult.parameters['date']) {
    // 天気API用にクエリを調整するため日付部分だけ抽出
    date = 
req.body.queryResult.parameters['date'].slice(0,10);
    console.log('Date: ' + date);
  }

  // 天気APIを呼ぶ
  callWeatherApi(city, date).then((output) => {
    res.json({ 'fulfillmentText': output }); // 天気APIの結果をDialogflowに返す
  }).catch(() => {
    res.json({ 'fulfillmentText': `I don't know the weather but I hope it's good!` });
  });
});

function callWeatherApi (city, date) {
  return new Promise((resolve, reject) => {
    // 天気を得るためHTTPリクエストのパスを作成する
    let path = '/premium/v1/weather.ashx?format=json&num_of_days=1' +
      '&q=' + encodeURIComponent(city) + '&key=' + wwoApiKey + '&date=' + date;
    console.log('API Request: ' + host + path);

    // 天気を得るためHTTPリクエストを作成する
    http.get({host: host, path: path}, (res) => {
      let body = ''; // 応答チャンクを格納する変数
      res.on('data', (d) => { body += d; }); // 各応答チャンクを格納する
      res.on('end', () => {
        // 全てのデータが受信されたら、JSONを解析して目的データを探す
        let response = JSON.parse(body);
        let forecast = response['data']['weather'][0];
        let location = response['data']['request'][0];
        let conditions = response['data']['current_condition'][0];
        let currentConditions = conditions['weatherDesc'][0]['value'];

        // レスポンスを作成
        let output = `${forecast['date']}${location['type']}${location['query']}は
        最高気温${forecast['maxtempC']}°C、最低気温は${forecast['mintempC']}°Cで
        ${currentConditions}の見込みだよ!`;

        // botが出力するテキストでPromiseをresolveする
        console.log(output);
        resolve(output);
      });
      res.on('error', (error) => {
        console.log(`Error calling the weather API: ${error}`)
        reject();
      });
    });
  });
}

デプロイ後は画面横のTry it nowから発話をテストできます。
こんな風に帰ってきたらOKです。

画像6

◼Flutter部分

それではいよいよ、Flutterでチャットボット・クライアントを作成していきます。

アプリ起動部分をlib/main.dartに、チャットボット部分をlib/dialogue_screen.dartに作成していきます。

また作成するクラスは大きく分けて3つで、それぞれ次の役割を持ちます。

・MyApp…アプリケーション本体。StatelessWidgetクラスで、起動時にDialogueScreenkクラスのインスタンスを作って表示する
・DialogueScreen…チャット画面を表示する。
・ChatMessage…メッセージを表示する。

解説の意味を込めてコメント部分に詳解を書いていたんですが、完成後に予想以上に見づらいことに気づいたので、また時間があるとき修正しておきます。_(:3 」∠)_

config/dialogflow.jsonにDialogflow認証情報を記述し、ボットアイコンをimg/bot.pngとして保存したら、あとは次のコードを作成してチャットボット完成です。

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_chatbot/dialogue_screen.dart';

// Flutterではmain関数がアプリ起動地点となる
// ここではrunApp関数でMyAppクラスのインスタンスを起動している
void main() => runApp(MyApp());

/* Flutterではウィジェットクラスは次のいずれかを継承して作る
 * ・StatelessWidget…ステート(状態を表す値)を持たないウィジェットのベースとなる
 * ・StatefulWidget…ステートを持つウィジェットのベースとなる
 * MyAppクラスはStatelessWidgetクラスを継承
*/ 
class MyApp extends StatelessWidget {
  // StatelessWidgetはbuildメソッドを持ち、ウィジェットが生成される際に実行される
  // また、このメソッドにはBuildContextというウィジェット組み込み状態(ウィジェットが組み込まれている親や子の情報)に関する機能がまとめたものが渡される
  @override
  Widget build(BuildContext context) {
    /* Materialマテリアルデザインを管理するMaterialDesignクラスを返す
     * 引数には画面に表示するウィジェットなどを引数で渡す
     * titleにはアプリタイトルを入力
     * themeにはテーマを指定
     * homeにはこのアプリに組み込まれるウィジェット(ここではDialogueScreenクラス)のインスタンスを渡す
    */
    return MaterialApp(
      title: 'Flutter Chatbot',
      theme: ThemeData(
        primarySwatch: Colors.cyan,
      ),
      home: DialogueScreen(),
    );
  }
}
// dialogue_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_dialogflow/dialogflow_v2.dart';

// DialogueScreenクラスはStatefulWidgetクラスを継承する
// ステートを持つ=動的に表示が作られるということ
// StatefulWidgetはウィジェット部分(StatefulWidget)とステート部分(Stateクラス)の2つで構成される
class DialogueScreen extends StatefulWidget {
  // DialogueScreenに渡したtitleがthis.titleにそのまま渡される
  DialogueScreen({Key key, this.title}) : super(key: key);
  final String title;

  // StatefulWidgetクラスにはcreateStateメソッドを実装する必要がある
  // createStateはステートを作成するためのメソッド
  // これで_DialogueScreenクラスがステートクラスとして扱われるようになる
  @override
  _DialogueScreen createState() => new _DialogueScreen();
}

// ステートクラスはStateクラスを継承して作成
// このときウィジェットクラスを<>で指定しておく
// これで指定したウィジェットクラスで使われるステートクラスが定義できる
// ステートクラスはbuildメソッドを持ち、これはステートを生成する際に呼び出され、ここでステートとして表示するウィジェットが生成して返す
// 言い換えると、ステートが更新されるたび、buildで新たな表示内容を生成して画面に表示する
class _DialogueScreen extends State<DialogueScreen> {
  final List<ChatMessage> _messages = <ChatMessage>[];
  final TextEditingController _textController = TextEditingController();

  // Controllerはウィジェットの値を管理するクラス
  // TextFieldのような入力を行うウィジェットは自身の中に値を保管するプロパティを持っている訳でなく、
  // 値を管理するControllerクラスを組み込み、これによって値を管理する
  // _textController.textで値を取り出す
  Widget _buildTextComposer() {
    return IconTheme(
        data: IconThemeData(color: Theme.of(context).accentColor),
        // コンテナ型(自身の中にウィジェットを組み込める)Containerウィジェット
        child: Container(
            margin: EdgeInsets.symmetric(horizontal: 8.0),
            // Rowウィジェットは複数のウィジェットを縦に配置するコンテナ型ウィジェット
            child: Row(
              children: <Widget>[
                Flexible(
                  child: TextField(
                    controller: _textController,
                    onSubmitted: _handleSubmitted,
                    decoration:
                        InputDecoration.collapsed(hintText: "Send a message"),
                  ),
                ),
                Container(
                  margin: EdgeInsets.symmetric(horizontal: 4.0),
                  child: IconButton(
                      icon: Icon(Icons.send),
                      // onPressedはイベント処理を設定するためのプロパティ
                      // ボタンをタップすると実行される
                      // ここにステート変更するメソッドを指定する
                      onPressed: () => _handleSubmitted(_textController.text)),
                )
              ],
            )));
  }

  void Response(query) async {
    _textController.clear();
    AuthGoogle authGoogle =
        await AuthGoogle(fileJson: "config/dialogflow.json").build();
    Dialogflow dialogflow =
        Dialogflow(authGoogle: authGoogle, language: Language.JAPANESE);
    AIResponse response = await dialogflow.detectIntent(query);
    ChatMessage message = ChatMessage(
      text: response.getMessage() ??
          CardDialogflow(response.getListMessage()[0]).title,
      name: "Bot",
      type: false,
    );
    // setStateメソッドはステートの更新を、ステートクラスに知らせる働きをする
    // このメソッドに、必要な値を変更する処理を実装する
    // ここでは_messagesプロパティの値を変更し、messageを追加する
    setState(() {
      _messages.insert(0, message);
    });
  }

  void _handleSubmitted(String text) {
    _textController.clear();
    ChatMessage message = ChatMessage(
      text: text,
      name: "You",
      type: true,
    );

    setState(() {
      _messages.insert(0, message);
    });
    Response(text);
  }

  // buildで生成されるウィジェットでは、ListViewのitemBuilder(表示部)とitemCount(要素数)に_messagesが使われている
  // _messagesプロパティの値が更新されるとbuildメソッドが再実行され、_messagesの値が変わる
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("Flutter Chatbot"),
        ),
        // Columnは複数のウィジェットを縦に配置するコンテナ型ウィジェット
        body: Column(
          children: <Widget>[
            Flexible(
                child: ListView.builder(
              padding: EdgeInsets.all(8.0),
              reverse: true,
              itemBuilder: (_, int index) => _messages[index],
              itemCount: _messages.length,
            )),
            Divider(
              height: 1.0,
            ),
            Container(
              decoration: BoxDecoration(color: Theme.of(context).cardColor),
              child: _buildTextComposer(),
            ),
          ],
        ));
  }
}
// dialogue_screen.dart
class ChatMessage extends StatelessWidget {
  ChatMessage({this.text, this.name, this.type});

  final String text;
  final String name;
  final bool type;

  List<Widget> otherMessage(context) {
    return <Widget>[
      Container(
        margin: EdgeInsets.only(right: 16.0),
        child: CircleAvatar(child: Image.asset("img/bot.png")),
      ),
      Expanded(
          child: Column(
        // Columnに組み込んだウィジェットの配置場所を指定
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(this.name, style: TextStyle(fontWeight: FontWeight.bold)),
          Container(
            margin: EdgeInsets.only(top: 5.0),
            child: Text(text),
          ),
        ],
      ))
    ];
  }

  List<Widget> myMessage(context) {
    return <Widget>[
      Expanded(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: <Widget>[
            Text(this.name, style: Theme.of(context).textTheme.subhead),
            Container(
              margin: EdgeInsets.only(top: 5.0),
              child: Text(text),
            ),
          ],
        ),
      ),
      Container(
        margin: EdgeInsets.only(left: 16.0),
        child: CircleAvatar(child: Text(this.name[0])),
      ),
    ];
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.symmetric(vertical: 10.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: this.type ? myMessage(context) : otherMessage(context),
      ),
    );
  }
}

◼おわりに

いかがでしたでしょうか?
書いてるうちに、FlutterよりDIalogflowについて長くなった気がしますが、きっとFlutterだとコードが短く書けるということですね(多分)
Flutterはまだまだ日本語ドキュメントは少ないので、英語は必要となりますが、期待度が高くこれからも追っていきたいです。

この記事が気に入ったらサポートをしてみませんか?