1. はじめに
テレビを見ているときにリモコンが見つからない場合や、テレビで面白い番組が放送されているかどうかを確認するために各チャンネルを切り替えるのが面倒な場合、Google アシスタントにテレビ番組を尋ねてみましょう。このラボでは、Dialogflow を使用して簡単なアクションを構築し、Google アシスタントと統合する方法を学びます。
ここでは、一般的なクラウド開発プロセスの手順に沿って以下の順序で演習を進めます。
- Dialogflow v2 エージェントを作成する
- カスタム エンティティを作成する
- インテントを作成する
- Firebase Functions でウェブフックを設定する
- チャットボットをテストする
- Google アシスタントの統合を有効にする
作業内容
Google アシスタント用のインタラクティブなテレビ番組表 chatbot エージェントを構築します。テレビガイドに、特定のチャンネルで現在放送されている番組を尋ねることができます。たとえば、「MTV で何が放送されている?」と尋ねると、テレビ番組表アクションによって、現在放送中の番組と次に放送される番組が返されます。 |
|
学習内容
- Dialogflow v2 で chatbot を作成する方法
- Dialogflow でカスタム エンティティを作成する方法
- Dialogflow で線形会話を作成する方法
- Dialogflow と Firebase Functions を使用して Webhook フルフィルメントを設定する方法
- Actions on Google を使用してアプリケーションを Google アシスタントに統合する方法
前提条件
- Dialogflow エージェントを作成するには、Google ID または Gmail アドレスが必要です。
- JavaScript の基本的な知識は必須ではありませんが、Webhook のフルフィルメント コードを変更する場合は役立ちます。
2. 設定方法
ブラウザでウェブ アクティビティを有効にする
- [ウェブとアプリのアクティビティ] が有効になっていることを確認します。

Dialogflow エージェントを作成する
- 左側のバーで、ロゴのすぐ下にある [Create New Agent] を選択します。既存のエージェントがある場合は、まずプルダウンをクリックします。

- エージェント名を指定します:
your-name-tvguide(自分の名前を使用)

- デフォルトの言語として [English - en] を選択します。
- デフォルトのタイムゾーンとして、最も近いタイムゾーンを選択します。
- [作成] をクリックします。
Dialogflow を構成する
- 左側のメニューで、プロジェクト名の横にある歯車アイコンをクリックします。

- エージェントの説明として「My TV Guide」と入力します。

- [Log Settings] までスクロールし、両方のスイッチをオンにして、Dialogflow のやり取りをログに記録し、Google Cloud Stackdriver のすべてのやり取りをログに記録します。これは、後でアクションをデバッグする場合に必要になります。

- [保存] をクリックします。
Actions on Google を構成する
- 右側のパネルの [Google アシスタントでの動作を確認する] で [Google アシスタント] リンクをクリックします。

http://console.actions.google.com が開きます。
Actions on Google を初めて利用する場合は、まずこちらのフォームに記入する必要があります。

- **プロジェクト名をクリックして、シミュレータでアクションを開いてみます。
- メニューバーで [テスト] を選択します。

- シミュレータが [英語] に設定されていることを確認し、[テストアプリに話しかけて] をクリックします。
アクションは、基本的な Dialogflow のデフォルト インテントで挨拶します。つまり、Action on Google との統合の設定が完了したということです。
3. カスタム エンティティ
エンティティは、アプリやデバイスがアクションを実行する対象となるオブジェクトです。パラメータ / 変数として考えてください。テレビガイドで「MTV で放送しているのは何?」と尋ねます。MTV はエンティティと変数です。「ナショナル ジオグラフィック」や「コメディ セントラル」など、他のチャンネルをリクエストすることもできます。収集されたエンティティは、TV Guide API ウェブサービスへのリクエストのパラメータとして使用されます。
Dialogflow エンティティの詳細はこちらをご覧ください。
チャンネル エンティティを作成する
- Dialogflow コンソールで、メニュー項目 [Entities] をクリックします。
- [エンティティを作成] をクリックします。
- エンティティ名:
channel(すべて小文字であることを確認してください) - チャンネル名を渡します。(Google アシスタントが別のことを理解した場合に備えて、同義語が必要になるチャンネルもあります)。入力中に Tab キーと Enter キーを使用できます。チャンネル番号を参照値として入力します。チャンネル名を同義語として指定します。
1 - 1, Net 1, Net Station 1

5**.** 青色の保存ボタンの横にあるメニューボタンをクリックして、**Raw Edit** モードに切り替えます。

- 他のエンティティを CSV 形式でコピーして貼り付けます。
"2","2","Net 2, Net Station 2"
"3","3","Net 3, Net Station 3"
"4","4","RTL 4"
"5","5","Movie Channel"
"6","6","Sports Channel"
"7","7","Comedy Central"
"8","8","Cartoon Network"
"9","9","National Geographic"
"10","10","MTV"

- [保存] をクリックします。
4. インテント
Dialogflow は、インテントを使用してユーザーの意図を分類します。インテントにはトレーニング フレーズが定義されます。トレーニング フレーズとは、ユーザーがエージェントに話しかける可能性のあるフレーズのサンプルです。たとえば、テレビで放送されている番組を知りたいユーザーは、「今日のテレビ番組は?」と尋ねる可能性があります。「現在放送中の番組を教えて」または「テレビ番組表」と話しかけます。
ユーザーが何かを書いたり読んだりすると(ユーザー表現と呼ばれます)、Dialogflow はユーザー表現をエージェントの最も適切なインテントとマッチングします。インテントのマッチングは「インテント分類」とも呼ばれます。
Default Welcome Intent を変更する
新しい Dialogflow エージェントを作成すると、2 つのデフォルト インテントが自動的に作成されます。Default Welcome Intent は、エージェントとの会話を開始したときに最初に到達するフローです。Default Fallback Intent は、エージェントがユーザーの発話を理解できない場合や、発話内容と一致するインテントが見つからない場合に実行されるフローです。
- [Default Welcome Intent] をクリックします。
Google アシスタントの場合、Default Welcome Intent で自動的に開始されます。これは、Dialogflow がウェルカム イベントをリッスンしているためです。ただし、入力したトレーニング フレーズのいずれかを言うことで、インテントを呼び出すこともできます。

Default Welcome Intent のウェルカム メッセージは次のとおりです。
ユーザー | エージェント |
「OK Google, your-name-tvguide と話して。」 | 「ようこそ。私はテレビガイド エージェントです。テレビチャンネルで現在放送中の番組をお知らせします。たとえば、「MTV で放送されている番組を教えて」と尋ねることができます。」 |
- [Responses] まで下にスクロールします。
- すべてのテキスト回答をクリアします。
- 次の挨拶を含む新しいテキスト レスポンスを 1 つ作成します。
Welcome, I am the TV Guide agent. I can tell you what's currently playing on a TV channel. For example, you can ask me: What's on MTV?

- [保存] をクリックします。
一時的なテストインテントを作成する
テスト用に一時的なテスト インテントを作成し、後で Webhook をテストできるようにします。
- [インテント] メニュー項目をもう一度クリックします。
- [インテントを作成] をクリックします。
- インテント名を入力します:
Test Intent(大文字の T と大文字の I を使用してください)。- インテントのスペルが異なると、バックエンド サービスは動作しません。

- [Add Training phrases] をクリックします。
Test my agentTest intent

- [Fulfillment] > [Enable Fulfillment](フルフィルメント > フルフィルメントを有効にする)をクリックします。

今回はレスポンスをハードコードしていません。レスポンスは Cloud Functions から返されます。
- [Enable Webhook call for this intent] スイッチを切り替えます。

- [保存] をクリックします。
チャネル インテントを作成する
チャンネル インテントには、会話のこの部分が含まれます。
ユーザー | エージェント |
「Comedy Central では何をやってる?」 | 「Comedy Central で午後 6 時からシンプソンズが放送されています。その後、午後 7 時からファミリー ガイが始まります。」」 |
- [インテント] メニュー項目をもう一度クリックします。
- [インテントを作成] をクリックします。
- インテント名を入力します:
Channel Intent(大文字の T と大文字の I を使用してください。- インテントのスペルが異なると、バックエンド サービスは動作しません。 - [Add Training phrases] をクリックして、次のフレーズを追加します。
What's on MTV?What's playing on Comedy Central?What show will start at 8 PM on National Geographic?What is currently on TV?What is airing now.Anything airing on Net Station 1 right now?What can I watch at 7 PM?What's on channel MTV?What's on TV?Please give me the tv guide.Tell me what is on television.What's on Comedy Central from 10 AM?What will be on tv at noon?Anything on National Geographic?TV Guide

- [Action and parameters] までスクロールします。

Dialogflow で認識されている @channel エンティティと @sys.time エンティティに注目してください。Webhook の後で、パラメータ名とパラメータ値がウェブサービスに送信されます。次に例を示します。
channel=8
time=2020-01-29T19:00:00+01:00
- channel を必須としてマークする
テレビガイド エージェントとの会話では、常にスロット パラメータ名 channel を入力する必要があります。会話の開始時にチャンネル名が言及されなかった場合、Dialogflow はすべてのパラメータ スロットが埋まるまで質問を続けます。
プロンプトに次のように入力します。
For which TV channel do you want to hear the tv guide information?In which TV channel are you interested?

- 時間パラメータは必須ではありません。
時間は省略可能です。時刻が指定されていない場合、ウェブサービスは現在の時刻を返します。
- [Fulfillment] をクリックします。
今回はレスポンスをハードコードしていません。レスポンスは Cloud 関数から返されます。[Enable Webhook call for this intent] スイッチをオンにします。
- [保存] をクリックします。
5. Webhook フルフィルメント
エージェントが静的インテント レスポンス以上のものを必要とする場合は、フルフィルメントを使用してウェブサービスをエージェントに接続する必要があります。ウェブサービスを接続すると、ユーザー表現に基づいてアクションを実行し、動的レスポンスをユーザーに返すことができます。たとえば、ユーザーが MTV のテレビ番組表を受け取りたい場合、ウェブサービスはデータベースをチェックし、MTV の番組表をユーザーに返します。
- メインメニューで [Fulfillment] をクリックします。
- [Inline Editor] スイッチを有効にする

シンプルな Webhook のテストと実装には、インライン エディタを使用できます。サーバーレスの Cloud Functions for Firebase を使用します。
- エディタの [index.js] タブをクリックし、次の Node.js コードの JavaScript をコピーして貼り付けます。
'use strict';
process.env.DEBUG = 'dialogflow:debug';
const {
dialogflow,
BasicCard,
Button,
Image,
List
} = require('actions-on-google');
const functions = require('firebase-functions');
const moment = require('moment');
const TVGUIDE_WEBSERVICE = 'https://tvguide-e4s5ds5dsa-ew.a.run.app/channel';
const { WebhookClient } = require('dialogflow-fulfillment');
var spokenText = '';
var results = null;
/* When the Test Intent gets invoked. */
function testHandler(agent) {
let spokenText = 'This is a test message, when you see this, it means your webhook fulfillment worked!';
if (agent.requestSource === agent.ACTIONS_ON_GOOGLE) {
let conv = agent.conv();
conv.ask(spokenText);
conv.ask(new BasicCard({
title: `Test Message`,
subTitle: `Dialogflow Test`,
image: new Image({
url: 'https://dummyimage.com/600x400/000/fff',
alt: 'Image alternate text',
}),
text: spokenText,
buttons: new Button({
title: 'This is a button',
url: 'https://assistant.google.com/',
}),
}));
// Add Actions on Google library responses to your agent's response
agent.add(conv);
} else {
agent.add(spokenText);
}
}
/* When the Channel Intent gets invoked. */
function channelHandler(agent) {
var jsonResponse = `{"ID":10,"Listings":[{"Title":"Catfish Marathon","Date":"2018-07-13","Time":"11:00:00"},{"Title":"Videoclips","Date":"2018-07-13","Time":"12:00:00"},{"Title":"Pimp my ride","Date":"2018-07-13","Time":"12:30:00"},{"Title":"Jersey Shore","Date":"2018-07-13","Time":"13:00:00"},{"Title":"Jersey Shore","Date":"2018-07-13","Time":"13:30:00"},{"Title":"Daria","Date":"2018-07-13","Time":"13:45:00"},{"Title":"The Real World","Date":"2018-07-13","Time":"14:00:00"},{"Title":"The Osbournes","Date":"2018-07-13","Time":"15:00:00"},{"Title":"Teenwolf","Date":"2018-07-13","Time":"16:00:00"},{"Title":"MTV Unplugged","Date":"2018-07-13","Time":"16:30:00"},{"Title":"Rupauls Drag Race","Date":"2018-07-13","Time":"17:30:00"},{"Title":"Ridiculousness","Date":"2018-07-13","Time":"18:00:00"},{"Title":"Punk'd","Date":"2018-07-13","Time":"19:00:00"},{"Title":"Jersey Shore","Date":"2018-07-13","Time":"20:00:00"},{"Title":"MTV Awards","Date":"2018-07-13","Time":"20:30:00"},{"Title":"Beavis & Butthead","Date":"2018-07-13","Time":"22:00:00"}],"Name":"MTV"}`;
var results = JSON.parse(jsonResponse);
var listItems = {};
spokenText = getSpeech(results);
for (var i = 0; i < results['Listings'].length; i++) {
listItems[`SELECT_${i}`] = {
title: `${getSpokenTime(results['Listings'][i]['Time'])} - ${results['Listings'][i]['Title']}`,
description: `Channel: ${results['Name']}`
}
}
if (agent.requestSource === agent.ACTIONS_ON_GOOGLE) {
let conv = agent.conv();
conv.ask(spokenText);
conv.ask(new List({
title: 'TV Guide',
items: listItems
}));
// Add Actions on Google library responses to your agent's response
agent.add(conv);
} else {
agent.add(spokenText);
}
}
/**
* Return a text string to be spoken out by the Google Assistant
* @param {object} JSON tv results
*/
var getSpeech = function(tvresults) {
let s = "";
if(tvresults['Listings'][0]) {
let channelName = tvresults['Name'];
let currentlyPlayingTime = getSpokenTime(tvresults['Listings'][0]['Time']);
let laterPlayingTime = getSpokenTime(tvresults['Listings'][1]['Time']);
s = `On ${channelName} from ${currentlyPlayingTime}, ${tvresults['Listings'][0]['Title']} is playing.
Afterwards at ${laterPlayingTime}, ${tvresults['Listings'][1]['Title']} will start.`
}
return s;
}
/**
* Return a natural spoken time
* @param {string} time in 'HH:mm:ss' format
* @returns {string} spoken time (like 8 30 pm i.s.o. 20:00:00)
*/
var getSpokenTime = function(time){
let datetime = moment(time, 'HH:mm:ss');
let min = moment(datetime).format('m');
let hour = moment(datetime).format('h');
let partOfTheDay = moment(datetime).format('a');
if (min == '0') {
min = '';
}
return `${hour} ${min} ${partOfTheDay}`;
};
exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
var agent = new WebhookClient({ request, response });
console.log('Dialogflow Request headers: ' + JSON.stringify(request.headers));
console.log('Dialogflow Request body: ' + JSON.stringify(request.body));
let channelInput = request.body.queryResult.parameters.channel;
let requestedTime = request.body.queryResult.parameters.time;
let url = `${TVGUIDE_WEBSERVICE}/${channelInput}`;
var intentMap = new Map();
intentMap.set('Test Intent', testHandler);
intentMap.set('Channel Intent', channelHandler);
agent.handleRequest(intentMap);
});

- エディタの [package.json] タブをクリックし、次の JSON コードをコピーして貼り付けます。このコードは、すべての Node.js パッケージ マネージャー(NPM)ライブラリをインポートします。
{
"name": "tvGuideFulfillment",
"description": "Requesting TV Guide information from a web service.",
"version": "1.0.0",
"private": true,
"license": "Apache Version 2.0",
"author": "Google Inc.",
"engines": {
"node": "8"
},
"scripts": {
"start": "firebase serve --only functions:dialogflowFirebaseFulfillment",
"deploy": "firebase deploy --only functions:dialogflowFirebaseFulfillment"
},
"dependencies": {
"actions-on-google": "^2.2.0",
"firebase-admin": "^5.13.1",
"firebase-functions": "^2.0.2",
"request": "^2.85.0",
"request-promise": "^4.2.5",
"moment" : "^2.24.0",
"dialogflow-fulfillment": "^0.6.1"
}
}

- [Deploy] ボタンをクリックします。サーバーレス関数をデプロイするため、しばらく時間がかかります。画面の下部にステータスを示すポップアップが表示されます。
- コードが機能するかどうかを確認するため、Webhook をテストしてみましょう。右側のシミュレータに次のように入力します。
Test my agent.
すべてが正しければ、「This is a test message」と表示されます。
- チャネル インテントをテストしてみましょう。次の質問をします。
What's on MTV?
すべてが正しければ、次のように表示されます。
「午後 4 時 30 分から MTV で MTV アンプラグドが放送されます。その後、午後 5 時 30 分からルポールのドラァグレースが始まります。」
省略可能な手順 - Firebase
別のチャネルでテストすると、テレビの結果は同じになります。これは、クラウド関数がまだ実際のウェブサーバーから取得していないためです。
これを行うには、アウトバウンド ネットワーク接続を行う必要があります。
ウェブサービスでこのアプリケーションをテストする場合は、Firebase プランを Blaze にアップグレードします。注: これらの手順は省略可能です。このラボの次のステップに進んで、Actions on Google でアプリケーションのテストを続けることもできます。
- Firebase コンソール(https://console.firebase.google.com)に移動します。
- 画面下部の [アップグレード] ボタンを押します。

ポップアップで Blaze プランを選択します。
- Webhook が機能することがわかったので、続行して
index.jsのコードを次のコードに置き換えます。これにより、ウェブサービスからテレビ番組ガイド情報をリクエストできるようになります。
'use strict';
process.env.DEBUG = 'dialogflow:debug';
const {
dialogflow,
BasicCard,
Button,
Image,
List
} = require('actions-on-google');
const functions = require('firebase-functions');
const moment = require('moment');
const { WebhookClient } = require('dialogflow-fulfillment');
const rp = require('request-promise');
const TVGUIDE_WEBSERVICE = 'https://tvguide-e4s5ds5dsa-ew.a.run.app/channel';
var spokenText = '';
var results = null;
/* When the Test Intent gets invoked. */
function testHandler(agent) {
let spokenText = 'This is a test message, when you see this, it means your webhook fulfillment worked!';
if (agent.requestSource === agent.ACTIONS_ON_GOOGLE) {
let conv = agent.conv();
conv.ask(spokenText);
conv.ask(new BasicCard({
title: `Test Message`,
subTitle: `Dialogflow Test`,
image: new Image({
url: 'https://dummyimage.com/600x400/000/fff',
alt: 'Image alternate text',
}),
text: spokenText,
buttons: new Button({
title: 'This is a button',
url: 'https://assistant.google.com/',
}),
}));
// Add Actions on Google library responses to your agent's response
agent.add(conv);
} else {
agent.add(spokenText);
}
}
/* When the Channel Intent gets invoked. */
function channelHandler(agent) {
var listItems = {};
spokenText = getSpeech(results);
for (var i = 0; i < results['Listings'].length; i++) {
listItems[`SELECT_${i}`] = {
title: `${getSpokenTime(results['Listings'][i]['Time'])} - ${results['Listings'][i]['Title']}`,
description: `Channel: ${results['Name']}`
}
}
if (agent.requestSource === agent.ACTIONS_ON_GOOGLE) {
let conv = agent.conv();
conv.ask(spokenText);
conv.ask(new List({
title: 'TV Guide',
items: listItems
}));
// Add Actions on Google library responses to your agent's response
agent.add(conv);
} else {
agent.add(spokenText);
}
}
/**
* Return a text string to be spoken out by the Google Assistant
* @param {object} JSON tv results
*/
var getSpeech = function(tvresults) {
let s = "";
if(tvresults && tvresults['Listings'][0]) {
let channelName = tvresults['Name'];
let currentlyPlayingTime = getSpokenTime(tvresults['Listings'][0]['Time']);
let laterPlayingTime = getSpokenTime(tvresults['Listings'][1]['Time']);
s = `On ${channelName} from ${currentlyPlayingTime}, ${tvresults['Listings'][0]['Title']} is playing.
Afterwards at ${laterPlayingTime}, ${tvresults['Listings'][1]['Title']} will start.`
}
return s;
}
/**
* Return a natural spoken time
* @param {string} time in 'HH:mm:ss' format
* @returns {string} spoken time (like 8 30 pm i.s.o. 20:00:00)
*/
var getSpokenTime = function(time){
let datetime = moment(time, 'HH:mm:ss');
let min = moment(datetime).format('m');
let hour = moment(datetime).format('h');
let partOfTheDay = moment(datetime).format('a');
if (min == '0') {
min = '';
}
return `${hour} ${min} ${partOfTheDay}`;
};
exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
var agent = new WebhookClient({ request, response });
console.log('Dialogflow Request headers: ' + JSON.stringify(request.headers));
console.log('Dialogflow Request body: ' + JSON.stringify(request.body));
let channelInput = request.body.queryResult.parameters.channel;
let requestedTime = request.body.queryResult.parameters.time;
let url = `${TVGUIDE_WEBSERVICE}/${channelInput}`;
if (requestedTime) {
console.log(requestedTime);
let offsetMin = moment().utcOffset(requestedTime)._offset;
console.log(offsetMin);
let time = moment(requestedTime).utc().add(offsetMin,'m').format('HH:mm:ss');
url = `${TVGUIDE_WEBSERVICE}/${channelInput}/${time}`;
}
console.log(url);
var options = {
uri: encodeURI(url),
json: true
};
// request promise calls an URL and returns the JSON response.
rp(options)
.then(function(tvresults) {
console.log(tvresults);
// the JSON response, will need to be formatted in 'spoken' text strings.
spokenText = getSpeech(tvresults);
results = tvresults;
})
.catch(function (err) {
console.error(err);
})
.finally(function(){
// kick start the Dialogflow app
// based on an intent match, execute
var intentMap = new Map();
intentMap.set('Test Intent', testHandler);
intentMap.set('Channel Intent', channelHandler);
agent.handleRequest(intentMap);
});
});
6. Actions on Google
Actions on Google は、Google アシスタントの開発プラットフォームです。これにより、サードパーティが「アクション」(拡張機能を提供する Google アシスタント用のアプレット)を開発できるようになります。
Google にアプリを開くか、アプリと会話するよう依頼して、Google アクションを呼び出す必要があります。
アクションが開き、音声が変更され、ネイティブの Google アシスタントのスコープから離れます。つまり、この時点からエージェントにリクエストするすべてのものは、ユーザーが作成する必要があります。Google アシスタントに Google の天気情報を尋ねたい場合は、まずアクションのスコープ(アプリ)を終了(閉じる)する必要があります。
Google アシスタント シミュレータでアクションをテストする
次の会話をテストしてみましょう。
ユーザー | Google アシスタント |
「OK Google, your-name-tv-guide と話して。」 | 「もちろんです。your-name-tv-guide を表示します。」 |
ユーザー | Your-Name-TV-Guide Agent |
- | 「ようこそ。テレビガイドです。」 |
エージェントをテストする | 「これはテスト メッセージです。このメッセージが表示されたら、Webhook のフルフィルメントが機能したことを意味します。」 |
MTV では何が見られる? | 午後 4 時 30 分から MTV で MTV Unplugged が放送されます。その後、午後 5 時 30 分からルポールのドラァグレースが始まります。 |
- Google アシスタント シミュレータに戻す
https://console.actions.google.com を開きます。
- マイクアイコンをクリックして、次のように質問します。

Talk to my test agentTest my agent
Google アシスタントは次のように応答します。

- ここで、次のように質問してみましょう。
What's on Comedy Central?
次のような出力が返されます
現在、コメディ セントラルで午後 6 時からシンプソンズが放送されています。その後、午後 7 時からファミリー ガイが始まります。
7. 完了
Dialogflow を使用して初めての Google アシスタント アクションを作成しました。お疲れさまでした。
お気づきかもしれませんが、アクションは Google アカウントに関連付けられたテストモードで実行されていました。iOS または Android スマートフォンの Nest デバイスまたは Google アシスタント アプリに同じアカウントでログインしている場合。アクションをテストすることもできます。
これはワークショップのデモです。ただし、Google アシスタント用のアプリケーションを実際に構築する場合は、アクションを提出して承認を受けることができます。詳しくは、こちらのガイドをご覧ください。
学習した内容
- Dialogflow v2 で chatbot を作成する方法
- Dialogflow でカスタム エンティティを作成する方法
- Dialogflow で線形会話を作成する方法
- Dialogflow と Firebase Functions を使用して Webhook フルフィルメントを設定する方法
- Actions on Google を使用してアプリケーションを Google アシスタントに統合する方法
次のステップ
この Codelab はお役に立ちましたか?これらの優れたラボをご覧ください。
この Codelab を Google Chat に統合して続行します。
