スマートスピーカーに自発的に喋らせよう (IFTTT + Sonos + AWS)

# 目的
Amazon Echo や Google Home などのスマートスピーカーは基本的にこちらから話しかけないと喋ってくれない。個人的に何か起きたらスマートスピーカー側から話して欲しかったのでそのための仕組みを作る。

# 必要なもの
- IFTTT
- Sonos スピーカー (Sonos One)
- AWS


Sonos スピーカーは IFTTT に対応してて API 経由で音声ファイルを再生できるスマートスピーカーで、一番安いのでも2.5万円ぐらいするけど便利だし楽しいしみんな買うといい（販促

# 構成
今回作ったのはGoogle カレンダーの予定を時間になったら喋らせる仕組み。ただし、パラメータ渡して API を叩いてるだけなので IFTTT 上のトリガーだったら Twitter だろうが Slack だろうがなんでも使える。
![ifttt_sonos_aws2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445743/73007979-f78c-4a2a-8910-0111940c49e4.png)


# 作ったもの
## IFTTT
作るアプレットは3種類
- Google Calendar の発火をトリガーに Web Request を飛ばす
- Webhook を受け取り Sonos に指定ファイルを再生させる
- Webhook を受け取り Sonos を停止させる

### Google Calendar の発火をトリガーに Web Request を飛ばす
Any events starts をトリガーにして Webhooks の Make a web request をアクションに設定。リクエストの設定は以下の通り。
- Method: Post
- Content Type: application/json
- Body: 

```json
{ "text": "{{Title}}の時間だああああああ", "voiceId": "Takumi", "during": 30 }
```

Body 内の各パラメータの意味
- text: 再生する音声
- voiceId: Polly の音声の種類 (日本語なら Takumi か Mizuki)
- during: 音声を再生する秒数

### Webhook を受け取り Sonos に指定ファイルを再生させる
トリガーを Webhooks の Receive a web request にし、Sonos の Play stream をアクションに設定。
Event Name は適当なものを設定し、What do you want to play のところに {{Value1}} を設定する。

### Webhook を受け取り Sonos を停止させる
トリガーを Webhooks の Receive a web request にし、Sonos の Pause をアクションに設定。

## AWS
### API Gateway
IFTTT から Webhook を受けるための入り口。
統合リクエストの設定は以下のように設定（参考：https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-api-gateway.html）
![APIGateway設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445743/59513bc5-febf-f935-920b-3683f70eed6b.png)
マッピングテンプレートは以下のように設定する。stateMachineArn には StepFunction のステートマシンの ARN を指定する。

```json
{
    "input": "$util.escapeJavaScript($input.json('$'))",
    "stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:HelloWorld"
} 
```

### Step Functions
音声を再生するだけなら Lambda だけでいいのだが、IFTTT の API だと Sonos が無限リピートで再生を始める。どこかで再生を止めるために、音声再生 → 指定時間後に音声を止める Lambda を実行するステートマシンを作成する。マシン定義は以下の通り。IFTTT から渡ってくる during パラメータの秒数分待機し、その後再生を停止する Lambda を実行する。

```json
{
  "StartAt": "PlayVoiceState",
  "States": {
    "PlayVoiceState": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:ap-northeast-1:1:123456789012:playVoiceOnSonos",
      "ResultPath":"$",
      "Next": "WaitSeconds"
    },
    "WaitSeconds": {
      "Type": "Wait",
      "SecondsPath": "$.during",
      "Next": "PauseVoice"
    },
    "PauseVoice": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:pauseSonos",
      "End": true
    }
  }
}
```

### Lambda
作ったものは以下の通り。
1. テキストから音声ファイルを作り、再生の Webhook を飛ばす Lambda
2. 再生停止の Webhook を飛ばす Lambda

実装はいずれも Node.js 10.x で実行ロールに Polly と S3 の権限がそれぞれ必要。

```javascript:テキストから音声ファイルを作り、再生のWebhookを飛ばすLambda
const crypto = require('crypto');
const https = require('https');
const AWS = require('aws-sdk');

const IFTTT_EVENT_NAME = process.env.IFTTT_EVENT_NAME;
const IFTTT_KEY = process.env.IFTTT_KEY;

const polly = new AWS.Polly();
const s3 = new AWS.S3();

const err_response = { statusCode: 500 };

exports.handler = async (event, context, callback) => {
    console.log(event);
    const pollyParams = {
        OutputFormat: 'mp3',
        VoiceId: event.voiceId,
        Text: event.text,
        TextType: 'text'
    };
    const pollyRes = await polly.synthesizeSpeech(pollyParams).promise();
    const s3Params = {
        Body: pollyRes.AudioStream, 
        Bucket: process.env.S3_BUCKET, 
        Key: crypto.randomBytes(32).toString('hex') + '.mp3'
    };
    const s3Res = await s3.upload(s3Params, async (err, data) => {
        if (err) {
            console.log(err.message);
            err_response['body'] = err.message;
            callback(JSON.stringify(err_response));
        }
    }).promise();
    const body = JSON.stringify({ value1: s3Res.Location });
    const httpsOptions = {
        hostname: 'maker.ifttt.com',
        path: `/trigger/${IFTTT_EVENT_NAME}/with/key/${IFTTT_KEY}`,
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        }
    };
    const res = await new Promise((resolve, reject) => {
        const iftttHttpReq = https.request(httpsOptions, (res) => {
            resolve(res);
        });
        iftttHttpReq.write(body);
        iftttHttpReq.end();
    });
    
    const response = event;
    response.statusCode = res.statusCode;
    
    context.succeed(response);
};
```

```javascript:再生停止のWebhookを飛ばすLambda
const https = require('https');

const IFTTT_EVENT_NAME = process.env.IFTTT_EVENT_NAME;
const IFTTT_KEY = process.env.IFTTT_KEY;

exports.handler = async (event, context) => {
    const httpsOptions = {
        hostname: 'maker.ifttt.com',
        path: `/trigger/${IFTTT_EVENT_NAME}/with/key/${IFTTT_KEY}`,
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        }
    };
    const response = await new Promise((resolve, reject) => {
        const iftttHttpReq = https.request(httpsOptions, (res) => {
            resolve(res);
        });
        iftttHttpReq.end();
    });
    context.succeed(response.statusCode);
};
```

### S3
バケットを用意するだけなのだが、時間が経つと余計な音声ファイルが残り続けるので、ライフサイクルルールで一定期間後に削除するよう設定するのがよい。

# 課題
- 再生中に他のイベントが発生すると次の再生が強制的に始まってしまう
  - 今回は実装を楽にするために IFTTT 経由で Sonos を操作しているけど、直接 API を叩けばキューに追加できるので暇があればその対応をするかも
- リピートしないで一回しか再生しないようにできない
  - 音声ファイルの再生時間を見るようにするか、直接 API で操作すればなんとかなりそう

# おわりに
他にもスマートロックをトリガーにおかえりメッセージ流すとか、暇そうにしてるときにツイッターのタイムライン流すとか、それ以外にも応用しがいがある気がするので、こういうアイデアあるぜ！みたいなのを教えてくれると嬉しい。