Google Nest Hubを使って、家庭内チャットをしよう

Google Nest Hubは、音声操作ができ、画面もついているので、誰でも何となく使うことができます。
そこで、Google Nest Hubの画面を使って、LINEのような機能を実現しようと思います。

全体構成は以下になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e1758194-beb9-8e46-c168-0ee7e79a38fe.png)

Nest Hubで使えるよう、Actions on GoogleのInteractive Canvasを利用します。
自宅にあるNest Hubでは、「OK Google、チャットキャンバスにつないで」というと、起動し、あとは発話でチャットを投稿します。
Interactive Canvasでは、受動的に他人がチャットを投稿されたことを知る方法が見当たらなかったので、Nest HubのInteractive Canvasの中で、定期的にWebAPI呼び出しで新しいチャットがないかを問い合わせるようにしました。
外出先で、チャットを投稿したり参照する際、スマホを使う場合でも、Actions on GoogleのInteractive Canvasが使えるので、同じ操作で利用できます。

また、直接Webページとして参照することで同様のことができるようにしましたので、WindowsやMacでも利用可能です。
で、これから立ち上げるNode.jsサーバが、これらの仲介をするように実装します。投稿されたチャットは、AWSのDynamoDBで保持するようにしました。

ソースコードもろもろは以下に上げました。

poruruba/ChatCanvas

https://github.com/poruruba/ChatCanvas

出来上がりの画面はこんな感じです。これが、Nest Hubの画面に表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b57a3225-80a4-16af-1bef-676db39020cd.png)

# Interactive Canvasのシーン設計

Interactive Canvasのプロジェクト登録は以下を参照してください。

　[Actions on GoogleのInteractive Canvasを試してみる](https://qiita.com/poruruba/items/84c75801d327d8fcc9b3)

ここでは、今回のシーン、カスタムインテント、タイプについて説明します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b732ede6-5788-5978-c087-253fb774ac2d.png)

その前に、セッションを通して保持する情報として、Session storageを利用します。ルーム名とキャラクタ名を保持します。
ルーム名は今回固定の「マイホーム」です。キャラクタ名は、自分がどのアイコンかを選択します。家庭内なので、特にログイン機能等は持たせていません。
Session storageに保持しておけば、Intentの通知の際に自動的につけてくれます。ただし、Storageには、Session storage、User storage、Home storageがあり、今回はセッション期間でのみ保持するSession storageです。

〇シーン

MainSceneという名前のシーンを1つ作ります。起動したらすぐにこのシーンに移行します。
後程説明する4つのインテントを待ち受けています。それぞれのインテントを検出すると、それぞれのイベントハンドラ名とパラメータ名を付けて、Node.jsサーバの方に転送されてきます。

(インテント名) → (イベントハンドラ名)
MyNameIntent → myname
TalkIntent → talk
SignIntent → sign
NextInetnt → continue

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/1f7cb6f5-2d0c-d4d3-c59c-01bc313c1a22.png)

〇タイプ

ユーザから入力されるコンテキストです。Free from text すなわち、自由文です。

・FreeType

キャラクタ名や投稿された言葉や絵文字です。実際には、キャラクタ名はアイコンのファイル名、絵文字名は絵文字アイコンのファイル名にしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9ecdf1c4-2ab3-f97c-e840-0eb3b96beffa.png)

〇カスタムインテント

以下の4つのインテントを作成します。いずれも、MainSceneで受け付けます。

・キャラクタ決定(MyNameIntent)

MainSceneに移行後、最初に投稿しようとしている人がだれかを決定します。アイコンの画像で区別がつきます。
実は、Session storageでキャラクタ名を管理しているので、このインテントの内容は使っておらず、次のチャット・絵文字投稿を促すメッセージを流すだけの目的になってます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7eb31dd4-1a34-e83b-3f2d-75bfb17a3c17.png)


・チャット投稿(TalkIntent)

「ねえねえ、ほにゃらら」と発話すると、ほにゃらら の部分をFreeTypeとして検出します。talkというパラメータ名にアサインしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/c1ced3cd-0c10-4d74-baf6-147cd63a78c2.png)

・絵文字投稿(SignIntent)
「そうそう、ほにゃらら」と発話すると、ほにゃららの部分をFreeTypeとして検出します。signというパラメータ名にアサインしています。といっても、「そうそう、ほにゃらら」と発話して使うものではなく、ユーザがボタンを押されたことを契機に、「そうそう、絵文字名」という言葉を疑似的に発したようにします。後程説明します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/1265fd57-9514-559c-69ea-6b78c90558dc.png)

・NextIntent

発話の待ち受けがタイムアウトした後、再度発話待ち状態にするためのものです。「継続して」という言葉にしています。これも、ユーザが明示的に発話するものではありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/c060cacf-0183-78c3-0b31-6d34ccae47ce.png)

# Node.jsサーバ

まずは、Actions on Googleからの転送を受け付ける部分。
以下のnpmモジュールを使わせていただいています。

@assistant/conversation

https://github.com/actions-on-google/assistant-conversation-nodejs

aws-sdk

https://github.com/aws/aws-sdk-js

なので、```aws configure``` でクレデンシャルを設定しておきます。


```js:/api/controllers/chatcanvas-api/index.js
const base_url_html = '【WebコンテンツのURL】';

const TABLE_NAME = "【DynamoDBのテーブル名】";
const LIST_LIMIT = 20;

var last_post = 0;

const {
	conversation,
	Canvas
} = require('@assistant/conversation')

const app = conversation({ debug: false });

app.handle('myname', async conv => {
    console.log(conv);
		conv.add('ねえねえ、の後に伝えたい言葉を言ってください。');
		conv.add(new Canvas({
			data: {
			}
		}));
});
app.handle('continue', async conv => {
	console.log(conv);
	conv.add('ねえねえ、の後に伝えたい言葉を言ってください。');
	conv.add(new Canvas({
		data: {
		}
	}));
});

app.handle('talk', async conv => {
    console.log(conv.intent.params);
    console.log(conv.context.canvas);

		conv.add('はい、次どうぞ。');
		if( conv.intent.params.talk ){
			var post_time = await put_chat(conv.context.canvas.state.room, conv.context.canvas.state.character, conv.intent.params.talk.resolved);
			conv.add(new Canvas({
					data: {
							post_time: post_time
					}
			}));
		}
});

app.handle('sign', async conv => {
	console.log(conv.intent.params);
	console.log(conv.context.canvas);

	conv.add('はい、次どうぞ。');
	if( conv.intent.params.talk ){
		var post_time = await put_chat(conv.context.canvas.state.room, conv.context.canvas.state.character, undefined, conv.intent.params.sign.resolved);
		conv.add(new Canvas({
				data: {
						post_time: post_time
				}
		}));
	}
});

app.handle('start', conv => {
	console.log(conv);
	conv.add('キャラクタを選択してください。');

	if (conv.device.capabilities.includes("INTERACTIVE_CANVAS") ){
		conv.add(new Canvas({
			url: base_url_html + '/chatcanvas/index.html',
//			continueTtsDuringTouch: true,
			enableFullScreen: true
		}));
	}else
	if (conv.device.capabilities.includes("RICH_RESPONSE")) {
		conv.add('キャラクタを選択してください。');
	}else{
		conv.scene.next.name = 'actions.scene.END_CONVERSATION';
		conv.add('この端末はディスプレイがないため対応していません。');
	}
});

app.handle('no_match', async conv => {
	console.log(conv);
	conv.add('もう一度言ってください。');
});

exports.fulfillment = app;


async function put_chat(room, character, message, sign)
{
	var time = new Date().getTime();

	if( !message && !sign )
		return time;

	var params_put = {
		TableName: TABLE_NAME,
		Item:{
			room: room,
			post_time: time,
			"character": character,
		},
//		ConditionExpression: 'attribute_not_exists(firstkey)',
	};
	if( message )
		params_put.Item.message = message;
	if( sign )
		params_put.Item.sign = sign;
//	console.log(params_put);
	var result = await docClient.put(params_put).promise();
	last_post = time;

	return time;
}
```

以下の部分を環境に合わせて変更してください。

```js:/api/controllers/chatcanvas-api/index.js
const base_url_html = '【WebコンテンツのURL】';
const TABLE_NAME = "【DynamoDBのテーブル名】";
```

DynamoDBのテーブルも作成しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/798663cc-4558-bc2b-98a7-ddd62ad9bac3.png)
 
次が、チャットのリストの取得などの、HTTP JSONのPost呼び出しの受け付け用です。

```js:/api/controllers/chatcanvas-api/index.js
const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');

const AWS = require("aws-sdk");
AWS.config.update({
  region: "ap-northeast-1",
});
const docClient = new AWS.DynamoDB.DocumentClient();

exports.handler = async (event, context, callback) => {
	var body = JSON.parse(event.body);
	console.log(body);

	switch(event.path){
		case '/chatcanvas-put-chat':{
			var time = await put_chat(body.room, body.character, body.message, body.sign);
			var result = {
				status: 'ok',
				time: time,
			};
			return new Response(result);
		}
		case '/chatcanvas-get-chat':{
			if( !body.end )
				body.end = new Date().getTime();
			var list = await get_chat(body.room, body.start, body.end);
			var result = {
				status: 'ok',
				list: list,
				time: body.end,
			};
			console.log(result);
			return new Response(result);
		}
	}
};

async function get_chat(room, start, end)
{
	if( last_post > 0 && last_post < start )
		return [];

	var params_query = {
		TableName: TABLE_NAME,
		ExpressionAttributeNames: {
			'#firstkey': 'room',
			'#secondkey': 'post_time'
		},
		ExpressionAttributeValues: {
			':firstValue': room,
			':secondValue_start': start,
			':secondValue_end': end,
		},
		ScanIndexForward: false,
		Limit : LIST_LIMIT,
		KeyConditionExpression: '#firstkey = :firstValue AND #secondkey BETWEEN :secondValue_start AND :secondValue_end',
	};
//		console.log(params_query);
		var result = await docClient.query(params_query).promise();
		if( result.Count > 0 )
			return result.Items.reverse();
		else
			return [];
}
```

結局のところ、エンドポイントは以下の3つです。

```yaml:/api/controllers/chatcanvas-api/swagger.yaml
paths:
  /chatcanvas-api:
    post:
      x-handler: fulfillment
      parameters:
        - in: body
          name: body
          schema:
            $ref: "#/definitions/CommonRequest"
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"

  /chatcanvas-put-chat:
    post:
      parameters:
        - in: body
          name: body
          schema:
            $ref: "#/definitions/CommonRequest"
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"

  /chatcanvas-get-chat:
    post:
      parameters:
        - in: body
          name: body
          schema:
            $ref: "#/definitions/CommonRequest"
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"
```

※注意
最近厳しくなってまして、Interactive CanvasのWebページのポート番号はHTTPSのポート番号443である必要があります。また、Interactive CanvasのWebページのJavascriptから呼び出されるWebAPIもHTTPSのポート番号443である必要があります。これはめんどい。。。(対応策を後述)


# クライアント側

まず、Interactive CanvasのためのJavascriptライブラリを読み込みます。

```html:/public/index.html
  <script src="https://www.gstatic.com/assistant/interactivecanvas/api/interactive_canvas.min.js"></script>
```

ページロード後に、以下を呼び出します。

```js:/public/js/start.js
        window.interactiveCanvas.getHeaderHeightPx()
        .then(height => {
            console.log("getHeaderHeightPx:" + height);
            this.margin = height;

            const callbacks = {
                onUpdate: (data) => {
                    console.log(data);
                    if( !this.connected ){
                        this.connected = true;
                    }
                }
            };
            window.interactiveCanvas.ready(callbacks);
    
            this.dialog_open('#character_select_dialog');
        });
```

``` window.interactiveCanvas.getHeaderHeightPx``` は、Interactive Canvasで表示されるヘッダーで、Webコンテンツが隠れないようにヘッダのサイズを取得しています。


``` window.interactiveCanvas.ready(callbacks);``` これがInteractive Canvasの初期化処理です。

``` this.dialog_open('#character_select_dialog');``` これは、クライアント起動後、まずはキャラクタアイコンを決めるためのダイアログを表示するためのものです。

以下の部分が、キャラクタ名をSession storageに格納し、MyNameIntentを発行しているところです。
``` window.interactiveCanvas.sendTextQuery()``` がまさに発話したことと同等なのです。Session storageへの設定は、``` window.interactiveCanvas.setCanvasState()```の部分です。

```js:/public/js/start.js
        start_chat: async function(){
            if( !this.icon_mine )
                return;
            window.interactiveCanvas.sendTextQuery("私の名前は " + this.icon_mine + " です。");
        },
        character_select: async function(){
            this.icon_mine = this.icon_list[this.icon_selecting];
            window.interactiveCanvas.setCanvasState({
                character: this.icon_mine,
                room: this.room
            });
            this.start_chat();
            this.dialog_close('#character_select_dialog');
```

一方、チャットの表示は、LINEチャット風にしたく、以下のサイトを参考にさせていただきました。ありがとうございました。

HTMLとCSSでLINE風チャット画面(会話方式)を記事に表示する方法(ほんとに売ってるラインスタンプイラスト41個付き)

https://nakox.jp/web/coding/chat_line_css

Actions on Googleによる発話処理は以下のような感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/15ca3ee0-5a38-2d70-706e-77e8f18ac457.png)

上記のsendTextQueryの部分が、ボタン押下をトリガに呼び出しています。
発話によるメッセージ投稿は、Actions on Googleが受け持ってくれるので、投稿自体は実装不要です。

```js:/public/js/start.js
        append_sign: async function(sign){
            if( this.connected ){
                window.interactiveCanvas.sendTextQuery("そうそう、" + sign);
            }else{
                var json = await do_post(base_url + "/chatcanvas-put-chat", {
                    room: this.room,
                    character: this.icon_mine,
                    redirect_uri: base_url2,
                    sign: sign });
                console.log(json);
            }
        },
```

# ソースコード

詳細は、GitHubをご参照ください。

poruruba/ChatCanvas

https://github.com/poruruba/ChatCanvas

# 使い方

「OK Google。チャットキャンバスにつないで」
と言って、作成したアプリを起動させます。そうすると、以下のようなキャラクタアイコンの選択画面が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7db7b4e2-3b56-777a-6f9c-b56aee8e3215.png)

あとは、「ねえねえ、遊ぼう」と言った感じで、ねえねえ、の後に好きな言葉をしゃべると、それが以下のチャットに投稿されます。参加している他の方には、選択したアイコンと、言った言葉が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b57a3225-80a4-16af-1bef-676db39020cd.png)


# 利用させていただいたコンテンツ

顔のアイコンは以下を使わせていただきました。
　https://www.irasutoya.com/2014/10/faceicons.html

絵文字アイコンは以下を利用させていただきました。
　https://icooon-mono.com/?s=%E5%96%9C%E6%80%92%E5%93%80%E6%A5%BD

HTMLとCSSでLINE風チャット画面(会話方式)を記事に表示する方法(ほんとに売ってるラインスタンプイラスト41個付き)
　https://nakox.jp/web/coding/chat_line_css

# その他

HTTPのポート番号443を用意できない方のために、Herokuを中継サーバにするためのソースも用意しておきました。

https://github.com/poruruba/ChatCanvas/tree/main/httpsproxy

以上