LINEのメッセージを定期的に確認してくれるAlexaスキルを作った

## はじめに

夕食の前後って、料理したり、ご飯食べたり、テレビ見たりしていて、ついスマホの通知を見逃すことが多いですよね。
せっかくLINEで「～時頃に家に着く」というメッセージが来ていても、帰宅まで気付かないなんてこともしばしば。
温めるのに時間のかかる料理だと特に、事前に気付いていればなぁ、と思ってしまいます。

そこで、帰宅に関するメッセージが来ていないか、Alexaに確認してもらえるようにします。

### 条件

使い勝手を考えた結果、以下の条件を満たすことを目的としました。

- 定期的に起動できる。
    - スマホのLINE通知を見逃しがち・意識から外れがちなことが発端なのに、ユーザーが定期的に意識してアレクサに確認する必要があっては本末転倒。
- 手動的に起動もできる。
- LINEの家族グループ内での投稿に反応できる。
    - メッセージの投稿主側のアクションも、意識する必要がないような、日常的な行動の範疇にしたい。
- Alexaスキルが自動で起動されても、ユーザーの反応がない場合は内容を読み上げず、かつ未確認の状態が保たれるようにしたい。
    - Alexaスキルが無人の状態で起動されて、誰も聞いていないのに確認済みと判断されると困る。


### 仕組み

![](https://i.imgur.com/LvbOJ04.png)

1. LINE botがユーザーから送信されたメッセージをGASにWebhookで送信(Post)する。
2. GASは受け取ったメッセージが、指定された条件を満たせばスプレッドシートに保存する。
3. Alexaスキルが呼び出された際に、GASにWebhookを送り、スプレッドシートに保存されているメッセージを取得する。
4. Alexaスキルがそのメッセージを読み上げる。

## LINE bot

Messaging APIを使います。
これはユーザーからのメッセージを受け取ったり、逆にメッセージを送ったりするためのAPIです。
一番botらしいことをするためのAPIと言えます。
今回は内部で利用するので使いませんが、一般に公開する場合はLogin APIを用いれば、ユーザーの情報を取得することもできます。

さて、このMessaging APIでは、scriptを用いる場合、外部とwebhookによるやりとりをすることになります。
(GUIによる応答のカスタマイズを利用するなら、LINE内部で完結させることは可能です。)

ここでは特に一般的なものとしてGoogle Apps Scripts(GAS)を用いました。
webhookのpostに対応できるなら、何でも構いません。

### webhook設定までの手順

bot作成の手順自体はQiitaを含めてたくさんの記事があるので、それらを参照してください。
例: [LINEのBot開発 超入門（前編） ゼロから応答ができるまで](https://qiita.com/nkjm/items/38808bbc97d6927837cd#webhook%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B)

Messaging API内で設定するwebhookのリンクは次節のGAS内で取得できます。`https:/https://script.google.com/macros/s/XXXXXXXXXXXXXXX/exec`という形式です。

## GAS

GASはJavascript(っぽい言語)が利用できます。
ただ、Javascirptとしてはかなり古いものしかサポートされておらず、`const`などは存在しません。全部`var`です。

スプレッドシートからGASを利用する方法は次の記事などを参照してください。
[今から10分ではじめる Google Apps Script(GAS) で Web API公開](https://qiita.com/riversun/items/c924cfe70e16ee3fe3ba)

では、Messaging APIからのwebhookを受け取るためのscriptを書きます。

### postData
GASにおいて、webhookのpostを受け取るための関数は`doPost()`という名前にすることになっています。

```javascript
function doPost(e) {
    var postData = JSON.parse(e.postData.getDataAsString());
}
```
これで`postData`にpostされたjson形式のdataが収まります。

Messaging APIからpostされるdataは次の形式になっています。

```javascript
postData={
"contents":{
    "events": [
        {
            "type": "message",
            "replyToken": "AAAAAAAAAAAAAA",
            "source": {
                "userId": "BBBBBBBBBBBBBBBBB",
                "type": "user"
            },
            "timestamp": 1618523340586,
            "mode": "active",
            "message": {
                "type": "text",
                "id": "CCCCCCCCCCCCCCCCCC",
                "text": "こんにちは"
            }
        }
    ],
    "destination": "DDDDDDDDDDDDDDDDDDDDD"
},
"length":301,
"name":"postData",
"type":"application/json"}
```

`event=postData.contents.events[0]`とします。

| 内容           |                      |   コメント                                                                      |
| -------------- | -------------------- | --------------------------------------------------------------------------- |
| メッセージ本文 | `event.message.text` |                                                                             |
| 投稿された場所 | `event.source.type`  | userは個人トークを指す。gorup、roomはそれぞれグループ、トークルームを指す。Idはそれぞれの識別番号。 |
| 投稿日時              | `new Date(event.timestamp).toLocaleString()`   |                                                                             |

詳しい内容はMessaging APIのreferenceでも見てもらえば分かります。

### sheet object

ではスプレッドシートのセルにpostされたデータを保存しましょう。
まずはスプレッドシートなどを扱うためのオブジェクトを取得します。

```javascript
var ss = SpreadsheetApp.getActive();
var sheet = ss.getActiveSheet();
```

スプレッドシートの最後の行に情報を追加する場合、`appendRow()`を使用します。

```javascript
var event = postData.contents.events[0];
var postText = event.message.text;
var time = event.timestamp;
sheet.appendRow([time, postText]);
```

### 取捨選択

今のままでは、botが受け取ったメッセージをすべてスプレッドシートに保存することになります。
アレクサにしゃべらせたいメッセージを識別するために、prefixを設定しましょう。

```javascript
var prefixs = ["^ね.{0,2}アレクサ", "^今"]; // 「ねえアレクサ」(等)、または「今」から始まるメッセージを対象とします。

var event = postData.events[0];
var type = event.message.type;
var IsText = (type === "text"); //textでない(画像や動画など)なら対象外
if (!IsText) return;
var postText = event.message.text;
var time = event.timestamp;

var IsTriggered = (RegExp(`${prefixs.join("|")}`).test(postText)); // prefixから始まるか判定します
if (!IsTriggered) return;

sheet.appendRow([time, postText]);
```

### 保存されたメッセージの取得

1行目は`time, text`などの見出しがあると想定し、メッセージ自体は2行目以降から取得しています。
jsonにまとめる際に、keyとして1行目の見出しを使用しています。

```javascript
var ss = SpreadsheetApp.getActive();
var sheet = ss.getActiveSheet();

var lastRow = sheet.getLastRow();
var maxKeysLen = 4; // 保存される内容は4列以内を前提としています。必要ならここの数字は余計なセルが含まれない範囲内で変更してかまいません。
var keys = sheet.getRange(1, 1, 1, maxKeysLen).getValues()[0].filter(d => d != ""); // 空白の見出し列は省略します。
var keysLen = keys.length;
var valuesFromSheet = sheet.getRange(2, 1, lastRow, keysLen).getValues()
    .filter(values => values.every(d => d != "")); // メッセージ自体は2行目以降から取得しています。
var jsonMessages = valuesFromSheet
    .map(jsonMessage => Object.assign(...keys.map((key, ind) => ({ [key]: jsonMessage[ind] })))); // 一つのjsonにまとめます。
    
var out = ContentService.createTextOutput(JSON.stringify(jsonMessages)); // textをdataとして含むresponceを作成します。
return out;
```

当初は`doGet()`を用いていたのですが、urlだけでアクセスできるgetではセキュリティ的に難があると判断し、
(非常に簡単にではありますが)tokenによる判定を行うために、メッセージの取得も`doPost()`で行います。

postされるDataとしては、次の形式を想定しています。
isCommandをkeyに含むかどうかで、LINE Messaging APIと区別しています。
また、tokenによる判定も行っています。(コード自体が公開されなければ、一応安全なはず。)

```javascript
{isCommand:"obtainMessages", token:"XXXXXXXXXX"}
```

```javascript
var token = { sheet: "XXXXXXX" };

function doPost(e) {
    // スプレッドシートオブジェクトを取得
    var prefixs = ["^ね.{0,2}アレクサ", "^今"];
    var ss = SpreadsheetApp.getActive();
    var sheet = ss.getActiveSheet();
    var postData = JSON.parse(e.postData.getDataAsString());

    var IsCommand = Object.keys(postData).indexOf("isCommand") != -1;
    var IsValidToken = Object.keys(postData).indexOf("token") != -1 && postData.token === token.sheet;
    if (IsCommand) {
        if (!IsValidToken) {
            var res = { error: "missing valid token in body" };
            var out = ContentService.createTextOutput(JSON.stringify(res));
            return out;
        } // validToken
        var res = {};
        if (postData.isCommand.indexOf("obtainMessages") != -1) {
            var lastRow = sheet.getLastRow();
            var maxKeysLen = 4;
            var keys = sheet.getRange(1, 1, 1, maxKeysLen).getValues()[0].filter(d => d != "");
            var keysLen = keys.length;
            var valuesFromSheet = sheet.getRange(2, 1, lastRow, keysLen).getValues()
                .filter(values => values.every(d => d != ""));
            var jsonMessages = valuesFromSheet
                .map(jsonMessage => Object.assign(...keys.map((key, ind) => ({ [key]: jsonMessage[ind] }))));
            res = Object.assign(res, { messages: jsonMessages });
        }
        var out = ContentService.createTextOutput(JSON.stringify(res));
        return out;
    }
```

### 保存されたメッセージの消去

スプレッドシートに保存されるメッセージはアレクサから一度確認できれば、それで十分なので、
消去するための機構も用意します。

```javascript
function clearCells() {
    var ss = SpreadsheetApp.getActive();
    var sheet = ss.getActiveSheet();
    var lastRow = sheet.getLastRow();
    sheet.getRange(2, 1, lastRow, 4).clearContent();
}

// 省略
if (IsCommand) {
    if (!IsValidToken) {
        var res = { error: "missing valid token in body" };
        var out = ContentService.createTextOutput(JSON.stringify(res));
        return out;
    } // validToken
            var res = {};
    if (postData.isCommand.indexOf("clearMessages") != -1) {
        clearCells();
        res = Object.assign(res, { clearMessages: true });
    }
    if (postData.isCommand.indexOf("obtainMessages") != -1) {
    // 省略
    }
    var out = ContentService.createTextOutput(JSON.stringify(res));
    return out;
}
```

## Alexa Skill
他のbot系と毛色が違ってGUIの管轄が広く、かなり手間取りました。

### 目標の挙動

```
ユーザー「アレクサ、LINEを確認」
Alexa「LINEに2件のメッセージがあります。」
ユーザー「読んで」
Alexa「1件目。18時24分。今大阪駅、19時過ぎに帰る。2件目……」
```

Alexaには定型アクションという機能があり、決まった時刻に指定したアクションを起こさせることができます。
これにより「定期的に起動」の条件を満たせます。

### skillの作成

Amazon Developer ConsoleからAlexa SkillをNode.jsのカスタムで新規作成します。
参照: [Amazon Echo ALEXA(Alexa Skill Set)カスタムスキル開発入門](https://qiita.com/Hoshi_7/items/9a79d4f00e3357faef05)

Skillの呼び出し名は、ここでは`ラインを確認`としておきます。

![](https://i.imgur.com/XLfU505.png)


### Intentの追加

`ユーザー「読んで」`に対応するセリフを設定します。

`ビルド>(サイドバーの)対話モデル>インテント`とたどってカスタムインテントを追加します。
インテントの名前は、ここでは`ReadIntent`としておきます。
インテントの呼びかけの設定は自由ですが、`読んで, 言って, はい`などが自然でしょうか。

![](https://i.imgur.com/krZcFXp.png)


### Script編集

ではAlexa Skillの中身となる部分を作っていきますが、その前にAlexa Skillの大まかな構造をお話しします。

1. ユーザーの呼びかけをAlexaが聞き取る
2. スキルの呼び出し名と一致していることを確認し、スキルにLaunchRequestを出す
3. LaunchRequest用のHandler (~関数)が起動する
    -  今回はここでGASから保存されているLINEのメッセージを取得し、「LINEに2件のメッセージがあります」などの応答をする。
4. ユーザーからの追加の呼びかけに待機する
5. ユーザーからの追加の呼びかけ(Intent)をAlexaが聞き取り、IntentRequestを出す。
   その呼びかけ内容がどのIntentに対応するかも判断される。
    - 「読んで」ならReadIntentと判断される
    - もし追加の呼びかけがなければ、スキルは終了する。
6. Intentに対応するIntentHandlerが起動する。
    - 具体的にメッセージを読み上げる。さらに、GASに保存されているメッセージを削除する。
7. タイムアウトまたはユーザーからの要請によりスキルが終了する。

他にもヘルプ用のHandlerや中止用のHandlerなどがあります。
スキルを作成する際に、テンプレートから作成すれば、メッセージの内容は英語ですが、あらかじめ用意されているものを利用できます。

ライブラリとしては次の2つをimportします。

```javascript
const Alexa = require('ask-sdk-core');
const fetch = require("node-fetch");
```

#### LaunchRequestHandler

スキルの起動処理LaunchRequestHandlerのScriptです。
内容は次の通りです。

> GASから保存されているLINEのメッセージを取得し、「LINEに2件のメッセージがあります」などの応答をする。

まずはLaunchRequestHandler全体を見ます。

```javascript
const token = { sheet: "XXXXXXX" };
const url_sheet = "https://script.google.com/macros/s/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
let messages = [];

const LaunchRequestHandler = {
    canHandle(handlerInput) {
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'LaunchRequest';
    },
    async handle(handlerInput) {
        const dataGet = { isCommand: "obtainMessages", token: token.sheet }
        const messagesTmp = await fetch(url_sheet, { method: "POST", body: JSON.stringify(dataGet) })
            .then(d => d.json()).then(d => d.messages);
        messages = Array.isArray(messagesTmp) ? messagesTmp : [];
        const messages_length = Array.isArray(messages) ? messages.length : 0;
        if (messages_length === 0) {
            const speakOutput = `<speak><phoneme alphabet="x-amazon-pron-kana" ph="ライン">LINE </phoneme>にメッセージはありません。</speak>`;
            return await handlerInput.responseBuilder
                .speak(speakOutput)
                .getResponse();
        } else {
            const speakOutput = `<speak><phoneme alphabet="x-amazon-pron-kana" ph="ライン">LINE </phoneme>に${messages.length}件のメッセージがあります。</speak>`;
            return await handlerInput.responseBuilder
                .speak(speakOutput)
                .reprompt(speakOutput)
                .getResponse();
        }
    }
};
```

前半のcanHandleは各Handlerが呼び出されるかどうかを判定しています。
今回はrequestがLaunchRequestであることが条件です。

```javascript
canHandle(handlerInput) {
    const request = handlerInput.requestEnvelope.request;
    return request.type === 'LaunchRequest';
},
```

GASからwebhookによりmessageを取得します。
内容についてはGASの節で述べた通りです。

```javascript
const token = { sheet: "XXXXXXX" };
const url_sheet = "https://script.google.com/macros/s/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
let messages = [];

const dataGet = { isCommand: "obtainMessages", token: token.sheet }
const messagesTmp = await fetch(url_sheet, { method: "POST", body: JSON.stringify(dataGet) })
    .then(d => d.json()).then(d => d.messages);
messages = Array.isArray(messagesTmp) ? messagesTmp : [];
```

保存されていたメッセージの件数に応じて、Alexaに応答させます。

```javascript
const messages_length = Array.isArray(messages) ? messages.length : 0;
if (messages_length === 0) {
    const speakOutput = `<speak><phoneme alphabet="x-amazon-pron-kana" ph="ライン">LINE </phoneme>にメッセージはありません。</speak>`;
    return await handlerInput.responseBuilder
        .speak(speakOutput)
        .getResponse();
} else {
    const speakOutput = `<speak><phoneme alphabet="x-amazon-pron-kana" ph="ライン">LINE </phoneme>に${messages.length}件のメッセージがあります。</speak>`;
    return await handlerInput.responseBuilder
        .speak(speakOutput)
        .reprompt(speakOutput)
        .getResponse();
}
```

ユーザーからの追加の呼びかけに待機する場合、`.reprompt(repromptOutput)`を挟みます。
待機時間は8 * 2秒間です。
8秒経過時に一度、`.reprompt()`内の内容を読み上げます。
上のコードではメッセージが0件だった場合、追加の呼びかけを受け取りません。

また`.speak()`などでAlexaに発声させる内容はただのStringで問題ありませんが、
イントネーションなどを調整したい場合、`<speak>`節で囲んだ上、`x-amazon-pron-kana`を利用します。(参照: [Amazon Polly を使用した日本語テキスト読み上げの最適化（後編）](https://recipe.kc-cloud.jp/archives/16823))

`handlerInput.responseBuilder.speak(speakOutput)`はcoroutineなので、`async/await`による非同期処理が利用できます。
その場合、`handle(handlerInput)`に`async`を付けるのを忘れないようにしましょう。

#### ReadIntentHandler

LaunchRequestHandlerの後、「読んで」と呼びかけられた場合に起動します。
内容は次の通りです。

> 具体的にメッセージを読み上げる。さらに、GASに保存されているメッセージを削除する。

こちらは目新しい内容もないので、解説は割愛します。

```javascript
const ReadIntentHandler = {
    canHandle(handlerInput) {
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'IntentRequest' &&
            request.intent.name === 'ReadIntent';
    },
    async handle(handlerInput) {
        const prefixs = ["^ね.{0,2}アレクサ"];
        const speech_main = messages.map((message, ind) => {
            const text = message.text
                .replace(RegExp(`${prefixs.join("|")}`), "");
            const time = new Date(message.time);
            const hour = (time.getHours() + 9) % 24; // UTCからの時差の補正
            const min = time.getMinutes();
            return `${ind + 1}件目。${hour}時${min}分。${text}`;
        }).join("...");
        messages = [];
        const data = { isCommand: "clearMessages", token: token.sheet }; // GASに保存されているメッセージを消去する。
        const opts = { method: "POST", body: JSON.stringify(data) };
        await fetch(url_sheet, opts).then(d => d);
        const speakOutput = speech_main;
        return await handlerInput.responseBuilder
            .speak(speakOutput)
            .getResponse();
    }
};
```

#### Handlerの登録

その他Handlerはテンプレート通りで問題ありませんが、一応GitHubにコードを載せておきます(そのうち)。
最後に各Handlerをskillに登録します。

```javascript
exports.handler = Alexa.SkillBuilders.custom()
    .addRequestHandlers(
        LaunchRequestHandler,
        ReadIntentHandler,
        HelpIntentHandler,
        CancelAndStopIntentHandler,
        FallbackIntentHandler,
        SessionEndedRequestHandler)
    .addErrorHandlers(
        ErrorHandler)
    .lambda();
```

## Future Work
- Alexaスキルが起動されて未確認のメッセージが0件の場合、手動起動なら0件であることを告げ、定期起動なら何も発言しないようにしたい。
    - 0件であることを定期的に告げられるとうるさい。