LINE Botで結婚式をペーパーレス化してみた

初投稿です。

![1572747636002.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/3b6ca904-8fa7-7c7e-1d1b-4e869512cee4.jpeg)
#ことのはじまり
妻と「結婚式の座席表だったりメニュー表だったりって持って帰ってきたあと捨てづらいよね」という話になり、自分たちの結婚式の招待状はWebを使用し、LINEで送る予定だったので、
**いっその事ペーパー類全部Lineで送ってしまえ！！**
とペーパレス結婚式をしてみました。

#実現したいこと  
どうせシステムを作るならあれもこれもとやりたいことをリストアップ  

- 招待状  
無料サービスを使用する予定だったが、自分で作っちゃう…？
- ペーパーレス化  
当日の配布物を画像で送信しペーパーレス化
- 立会人抽選  
立会人をお願いしようと考えてた方が仕事の都合で来られなくなったので、立会人を当日その場で抽選して決定しまおう！  
- 投稿写真スライドショー  
特に余興を予定していなかったので余興替わりにと導入
- エンドロールムービー自動生成  
投稿してもらった写真で自動でエンドロールムービー出力すればムービー作成の手間が省けそう

結果から言いますと、招待状は製造が間に合わず断念、エンドロールムービーはちょっとした小ネタを入れたくなり普通に作成したため着手せず終わりました。残りのペーパーレス化、立会人抽選、投稿写真スライドショーはLINE Botで開発しました。

#招待状
もともとは無料サービスを使用する予定でしたが、システムを開発するなら招待状も自分で作ってみる…?と、興味のあったVue.jsの勉強がてら挑戦してみました。(この時、招待状送付日まで残り1週間)
### システムイメージ
- Googleフォームでアンケートを作る
- Vue.jsで作ったサイトにGoogleフォーム埋め込む
- GitHub Pagesで公開

Googleフォームから各種IDを抜き出し、独自に入力フォームを作ることはできましたが、ゴリゴリのバックグラウンドおじさんの私には初めて触るVue.jsで自分の納得できる状態まで持っていけなかったので断念しました…。

# LINE Bot作成
もともと招待状をLINEで送る予定だったので、ゲストの方全員LINEインストール済み(親族のお年寄り除く)であり、全ての機能を賄えるプラットフォームなのでLINE Botで作成しました。

※以下のスクショは備忘録として取っていたものなので、現在の画面とデザインが異なっているようです。
### LINE Developers
- LINE DevelopersはLINEのアカウントを持っていれば誰でもフリープランが使用可能

### プロバイダー作成
まずはプロバイダーの作成を行います。
アプリの提供者は誰というだけなので、
今回は実名で作成しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/74920a5b-9418-db2b-8ce3-ca384130e03d.png)

### Messaging APIのチャンネル作成
今回はLine Botの作成なのでMessaging APIを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/f7b68faf-fdcc-7ff9-95ad-3faf2eb05f1d.png)

作成するBotの情報を入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/376287c2-abb5-595a-4218-bb1037ea2acd.png)

- (任意)アプリアイコン画像 : LINE Botのアイコン
- (必須)アプリ名 : LINE Botの名前
- (必須)アプリ説明 : どこから参照できるのか不明…とりあえず適当にいれました
- (必須)大業種/小業種　: どこから参照できるのか不明…とりあえず適当にいれた
- (必須)メールアドレス : 自分のメールアドレス
- (任意)プライバシーポリシーURL　: 任意なので無視しました
- (任意)サービス利用規約URL　: 任意なので無視しました

入力後、利用規約うんぬんを同意同意…で進むと、作成されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/d498b099-2543-f9d4-aaed-27e2eaf85a98.png)


作成したチャンネルを選択すると諸々の設定が行えます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/90e26d68-59fe-099b-1824-60a1c0fd2037.png)

定期的にメッセージ配信する等の利用方法の場合はコンソールからの操作のみで行えます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/d93e53d4-bacb-5645-ca0d-7e75853b46c3.png)

### 確認
チャンネル基本設定の下の方にQRコードがあるので、LINEで読み取り友達追加します。
これでひとまずBot本体の準備は完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/220dcf77-4dc6-39a9-95c0-f56e771427bf.png)


### Botの作りこみ
初めに外部連携できるよう設定を行います。
### Webhook
Webhook送信を利用するに設定します。
ここを利用するにしても一向に送信してる気配がない場合は、
下部に見える**「設定はこちら」**を押下するとまた別の設定管理画面に遷移します。
こちらのWebhookをONになっていない場合があります。
ついでに応答メッセージもOFFにします。
ONになっているとメッセージを送った際に設定されている定型文を返信します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/11b429fd-d325-3dc1-5e8a-d040627a097c.png)

### 環境準備
LineBotで検索するとPythonばかり…
Python未経験なのでNode.jsで行うことにしました。
※既に環境があったからというだけの理由です。

まずはpackage.jsonを作成。

```
npm init -y
```
次に公式SDKを引き込みます。

```
npm i @line/bot-sdk express
```

まずは環境確認の為にHello Worldします。

```linebot.js
'use strict';

const express = require('express');
const PORT = process.env.PORT || 3000;
const app = express();

app.get('/', (req, res) => res.send('Hello World'));

app.listen(PORT);
console.log(`Server Running @ ${PORT}`);
```
起動を確認

```
C:\Project\Src\linebot>node linebot.js
Server running at 3000
```

localhost:3000にアクセスしHello Worldを確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/3704cb99-b365-b2eb-c304-b4aa508f167b.png)

### post処理
次に実際にLINE経由でメッセージを受取り、応答できるか試します。

LINEのチャンネル設定から以下をコピーしてきます。
- channelSecret
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/d2ffd8e5-431b-1c16-4764-b18515a5548f.png)

- channelAccessToken
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/b8dfe704-d0dc-09cc-1481-3ce65d772cef.png)


```linebot.js
'use strict';

const line = require('@line/bot-sdk');
const https = require('https')
const fs = require('fs');
const express = require('express');
const PORT = process.env.PORT || 3000;
const app = express();

const config = {
    channelSecret: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
    channelAccessToken: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
};
const client = new line.Client(config);

app.get('/', (req, res) => res.send('Hello World'));
app.post('/webhook', line.middleware(config), (req, res) => {
    console.log(req.body.events);
    Promise
      .all(req.body.events.map(handleEvent))
      .then((result) => res.json(result));
});

// 応答処理
function handleEvent(event) {
  return client.replyMessage(event.replyToken, {
    type: 'text',
    text: event.message.text//受け取ったメッセージを返却してみる
  });
}

app.listen(PORT);
console.log(`Server running at ${PORT}`);
```

### ngrokでトンネリング
localhostにグローバルからアクセスできるようにトンネリングツールを使用します。
ngrokをインストール

```
npm i -g ngrok
```
今回は3000ポートで起動しているので3000を指定して起動

```
 ngrok http 3000
```
```
ngrok by @inconshreveable                                                                               (Ctrl+C to quit)

Session Status                online
Session Expires               7 hours, 59 minutes
Version                       2.3.34
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://134d8e71.ngrok.io -> http://localhost:3000
Forwarding                    https://134d8e71.ngrok.io -> http://localhost:3000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00
```
Forwarding に記載されてるURLでlocalhostにアクセスできます。
私はこの時使ったことのあったngrokを使用しましたが、ngrokは起動の度にURLが変わります(有料版だと固定できる)。
今は[Serveo](http://serveo.net/)というアドレスが固定できるサービスがあるようなのでこちらを使ったほうが楽そうです。

チェンネルの設定画面にURLを転記します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/94af5a19-cb72-2f05-87b1-23f0f09daebc.png)
無効なステータスコードエラーならURLの設定が間違ってます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/0ef7d617-430f-b323-36a1-0b4d78d9dd17.png)
正しく通信できていれば接続確認で通信エラーのメッセージが出ます(矛盾)。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/551573cc-7389-cdee-f799-5f0cb90f8fa3.png)
ngrokにPOTSTのリクエストが届いているのが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/34685920-6f86-810b-892b-b679f03a4599.png)

通信エラーの原因はソースに接続確認用のコードを書かないといけないようでした。
ローカルに届いてることは確認できたので今回は書きません。

### 動作確認
Lineからメッセージを送ってみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/730273a1-077f-41e3-e751-a99f4cbcd3d4.png)
無事メッセージが返ってきました。

#投稿写真スライドショー機能
メッセージが受け取れることを確認できたので、その流れで投稿写真スライドショー機能から作成しました。

###メッセージ種別判定
message.typeで判定できます。
typeには以下があるようです。
- テキストメッセージ
- スタンプメッセージ
- 画像メッセージ
- 動画メッセージ
- 音声メッセージ
- 位置情報メッセージ
- イメージマップメッセージ
- テンプレートメッセージ
- Flex Message

```linebot.js
// 応答処理
function handleEvent(event) {
    var msg;
    // テキストの場合
    if (event.message.type === 'text') {
        msg = '文字でーす！！'
    }
    // 画像の場合
    else if (event.message.type === 'image') {
        msg = '画像でーす！！'
    }

    return client.replyMessage(event.replyToken, {
      type: 'text',
      text: msg
    });
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/686e9568-2a3b-bc36-1906-a3b7274875ae.png)

### 画像を保存
送られてきた画像をローカルへ保存します。
画像は```https://api.line.me/v2/bot/message/{messageId}/content```で取得できます。

```linebot.js
// 応答処理
function handleEvent(event) {
    // テキストの場合
    if (event.message.type === 'text') {
        return client.replyMessage(event.replyToken, {
            type: 'text',
            text: '文字でーす！！'
        });
    }
    // 画像の場合
    else if (event.message.type === 'image') {
        var msg;
        var promise = getImage(event.message.id, config.channelAccessToken);
        promise.then(function(value) {
          fs.writeFileSync(`./image.jpg`, new Buffer.from(value), 'binary');
          return client.replyMessage(event.replyToken, {
            type: 'text',
            text: '保存しました。'
          });
        });
    }
}

// 画像取得
function getImage(messageId, token) {
    return new Promise((resolve, reject) => {
        const send_options = {
            host: 'api.line.me',
            path: `/v2/bot/message/${messageId}/content`,
            headers: {
                'Content-type': 'application/json; charset=UTF-8',
                Authorization: ` Bearer ${token}`,
            },
            method: 'GET',
        }

        const req = https.request(send_options, function(res) {
            var data = []
            res.on('data', function(chunk) {
                data[data.length] = chunk
            })
                .on('error', function(err) {
                    console.log(err)
                    reject(err)
                })
                .on('end', function() {
                    resolve(Buffer.concat(data))
                })
        })
        req.end()
    })
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/42675db0-ec47-850a-23ee-09b814886c6d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/e5509c81-7065-2b19-83c1-211c347464a4.png)

無事保存できました！
ちなみに画像は全てjpgになる仕様だそう。

本番ではファイル名はgetTimeの値にし、保存パスを./image/${date}.jpgとし
imageフォルダをスライドショーさせました。

###画像をGooglフォトにアップロード…
画像をGooglフォトにアップロードし、Googlフォトのアプリでスライドショーさせればよいかなと考えていたのですが、Googlフォトのスライドショーがスライドショー開始時点にあった分の画像しかスライドショーしてくれず、リアルタイムにアップロードした画像を反映してくれなかった為断念しました。
結果、当日会場のプロジェクターに繋いだノートPC上でLINE Botを動かしローカルに保存した画像をそのままスライドショーさせることにしました。(LINE Botのデプロイ先問題も一気に解決！)

#ペーパーレス化
当日配布するものとしては以下がありました。

- 席次表
- メニュー表
- 日本酒表　※日本酒好きの私が当日6升持込みで振舞い酒をした為
- 2次会案内

### 友達登録時に座席表とメニュー表を送る
まずは〇〇表系から
こちらは受付時にLINE Botを友達登録してもらったタイミングに送付するようにしました。

コンソール画面から友達追加時に自動で送るメッセージ内容を設定できますが、
1メッセージ(or1画像)しか設定できないため今回は使用しません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/1594a314-f82e-8a3a-54a1-6eba6de7364b.png)

友達追加は```event.type```が```follow```になります。
因みにブロック解除も```follow```、ブロックが```unFollow```なのでブロック解除で友達追加時のテストができます。

```linebot.js
// 応答処理
function handleEvent(event) {
    // 友達追加
    if (event.type === 'follow') {
        client.replyMessage(event.replyToken,
        [
            {
                type: 'text',
                text: '本日はご多用のところ\nお越しくださり\n誠にありがとうございます\n\n皆様にあたたかく見守られ\n今日の日を迎えられることを\n心より感謝しております\n\nおいしい料理とお酒を\nご用意いたしましたので\nごゆっくりおくつろぎください\n\n未熟な２人ではございますが\nこれからお互い支え合い\nわたしたちらしい自由な家庭を\n築いていきたいと思います\n\n今後ともよろしくお願いいたします'
            },
            {
                type: 'text',
                text: '披露宴会場の座席表と\n本日のメニュー表でございます'
            },
            {
                type: 'image',
                originalContentUrl: 'https://i.gyazo.com/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.png',
                previewImageUrl: 'https://i.gyazo.com/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.png'
            },
            {
                type: 'image',
                originalContentUrl: 'https://i.gyazo.com/4165d7b64aecf8bb343f024ebea38d16.png',
                previewImageUrl: 'https://i.gyazo.com/4165d7b64aecf8bb343f024ebea38d16.png'
            },
            {
                type: 'image',
                originalContentUrl: 'https://i.gyazo.com/35a320204db888ba46e6b8eca4f76953.png',
                previewImageUrl: 'https://i.gyazo.com/35a320204db888ba46e6b8eca4f76953.png'
            }
        ]);
    }
    else if (event.type === 'message')
    {
          if (event.message.type === 'image') {
            var msg;
            var promise = getImage(event.message.id, config.channelAccessToken);
            promise.then(function(value) {
              fs.writeFileSync(`./image.jpg`, new Buffer.from(value), 'binary');
              return client.replyMessage(event.replyToken, {
                type: 'text',
                text: '保存しました。'
              });
            });
        }
    }
}
```

1度にリプライで複数メッセージを返すにはreplyMessageのメッセージを設定する引数に配列で渡してやれば順番にメッセージを送信してくれます。

![S__121487364.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539781/e885ec10-6748-a48b-53d7-5500ad15926d.jpeg)


###ハマったポイント①
配列にすることで複数メッセージ送れるのですが、最大5メッセージ迄のようで、

1. 挨拶分
2. 座席表等送ります的なメッセージ
3. 座席表
4. メニュー表
5. 日本酒表
6. 画像投稿でスライドショーされるお知らせ

を送ろうとしていた為、6番を披露宴開始時に送るように変更しました。
 
###ハマったポイント②
画像はローカルファイルを送る等はできず、URLを設定しなければなりません。
初めの挙動確認はGoogleフォトにアップロードし、共有リンクを使用していて問題なかったのですが、
本番3日前に唐突にGoogleフォトの写真が取得できなくなりました。(PC版LINEだと取得できた)
アップロード先を[Gyazo](https://gyazo.com/ja) というサービスに切り替えました。

#立会人抽選 
画像投稿でスライドショーされるお知らせと2次会案内は一旦置いておいて、立会人抽選を作成します。
プランナーさんと相談した結果、抽選操作は私がした方が良いということになり、LINE Botにトリガーとなるキーワードを送る方法で抽選を開始するようにしました。

### 友達に登録されたIDを記録
友達追加時に相手のIDをテキストファイルに保持しておきます。
私もトリガーワードを送るため友達追加しないといけないので、追加されたIDを削除し当日ゲストのみのIDだけ記録された状態にすることで、自分自身が当選することを回避しました。

```linebot.js
// 応答処理
function handleEvent(event) {
    // 友達追加
    if (event.type === 'follow') {
        fs.appendFileSync(idList, event.source.userId+'\n', charset);
```

### 抽選トリガー
LineBotに「抽選」というメッセージを送ると抽選発動
先ほど保持して置いたIDの中から2名ランダムで選び当選のメッセージを送信します。

```linebot.js
    else if (event.type === 'message')
    {
        // テキストの場合
        if (event.message.type === 'text') {
            if (event.message.text === '抽選') {
                var text = fs.readFileSync(idList, {encoding: charset});
                var lines = text.toString().split('\n');
                var win01 = getRandomInt(lines.length - 1);
                var win02 = win01;
                while(win01 == win02)
                {
                    win02 = getRandomInt(lines.length - 1);
                }
                return winPush(lines[win01], lines[win02]);
            } else {
                return client.replyMessage(event.replyToken, {
                    type: 'text',
                    text: '個別返信には対応しておりません。。。'
                });
            }
        }

![S__121487367.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539781/7be02b00-5523-1cb4-36fe-41e410fe0161.jpeg)

// 当選通知
function winPush(win01, win02) {
    console.log('win01:'+win01);
    console.log('win02:'+win02);

    const message = {
        type: 'text',
        text: '当選'
      };

    client.pushMessage(win01, message);
    client.pushMessage(win02, message);
}
```
![S__121487367.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539781/7893694b-96c8-2b97-e0a5-030435fee67b.jpeg)


本番ではプランナーさんから新郎側ゲストから1名、新婦側ゲストから1名選ぶようにと言われていたので新郎Botをポート3000新婦Botをポート4000で2つLINE Botを用意し対応しました。

# 画像投稿でスライドショーされるお知らせ & 2次会案内
最後に適時送りたいお知らせです。
こちらは先ほど作成した抽選と同じ仕組みでトリガーワードで送信処理を開始するようにしました。

```linebot.js
    else if (event.type === 'message')
    {
        // テキストの場合
        if (event.message.type === 'text') {
            if (event.message.text === '抽選') {
                var text = fs.readFileSync(idList, {encoding: charset});
                var lines = text.toString().split('\n');
                var win01 = getRandomInt(lines.length - 1);
                return winPush(lines[win01]);
            } else if (event.message.text === '披露宴') {
                var text = fs.readFileSync(idList, {encoding: charset});
                var lines = text.toString().split('\n');
                return RecInfoPush(lines);
            } else if (event.message.text === '2次会') {
                var text = fs.readFileSync(idList, {encoding: charset});
                var lines = text.toString().split('\n');
                return SecInfoPush(lines);
            } else {
                return client.replyMessage(event.replyToken, {
                    type: 'text',
                    text: '個別返信には対応しておりません。。。'
                });
            }
        }
        // 画像の場合
        else if (event.message.type === 'image') {
            var msg;
            var promise = getImage(event.message.id, config.channelAccessToken);
            var date = new Date().getTime();
            promise.then(function(value) {
              fs.writeFileSync(`./image/${date}.jpg`, new Buffer.from(value), 'binary');
              return client.replyMessage(event.replyToken, {
                type: 'text',
                text: '保存しました。'
              });
            });
        }
    }
}

// 披露宴
function RecInfoPush(ids) {
    ids.forEach(function(id) {
        const message = {
            type: 'text',
            text: 'こちらのＬＩＮＥに\nお写真を投稿いただきますと\n会場のプロジェクターにて\nスライドショーいたします\n\n是非とも本日のお写真や\n思い出のお写真の投稿を\nお願いいたします'
        };
        client.pushMessage(id, message);
    });
}

// ２次会
function SecInfoPush(ids) {
    ids.forEach(function(id) {
        const message =
            [
                {
                    type: 'text',
                    text: '【2次会のお知らせ】\n2次会は新郎新婦が\n来賓の皆様と即興バンドを組む\nライブを開催いたします\n\n皆様とお酒を飲みながら\n楽しい時を過ごせればと\n思っておりますので\n是非ともご出席いただきたく\nご案内申し上げます'
                },
                {
                    type: 'text',
                    text: '【日時】\n本日11月3日\nOPEN 15:00\nSTART 15:30\nEND 18:30\n\n【会費】\n3,000円\n飲み放題＋軽食'
                },
                {
                    type: 'location',
                    title: 'CURTAIN CALL',
                    address:'札幌市中央区南3条西4 浅野ビル2F',
                    latitude: 43.0561223,
                    longitude:141.3522035
                }
            ];

        client.pushMessage(id, message);
    });
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/d54f16fa-b344-7c53-d446-28786ebe2b91.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453265/9e17a586-d76b-669b-d20c-5004a767e65d.png)

2次会案内は会場をGoogleMapで送れるので、誰一人迷わず来られたみたいでとても良かったです。

# 最終構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539781/2157b68f-c79a-8555-ea51-0221deb97829.png)

# 最後に
当日ノートPCが1時間無操作でスリープする設定になっていたことに気が付かず、途中から「友達登録しても反応がない！！」とトラブルもありましたがペーパーレスシステムは好評だったようで作ってよかったなと思いました。
終わってから、あんなこともしたかった！が色々湧いてきたので友人が結婚式を挙げるときに使いたいという話があればグレードアップさせていきたいなと思います。

# おまけ
インパクトのある入場をしてしまったため、印象をそちらに持っていかれて式後にゲストの方とお話てもそっちの話題ばかりLINE Botは話題に上がらず…
毎日コツコツ作ったのに…かなしい…

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">こちら先日の宝島入場になります。<br>ご査収ください。<a href="https://twitter.com/hashtag/%E6%96%B0%E5%AE%9D%E5%B3%B6?src=hash&amp;ref_src=twsrc%5Etfw">#新宝島</a><a href="https://twitter.com/hashtag/%E7%B5%90%E5%A9%9A%E5%BC%8F?src=hash&amp;ref_src=twsrc%5Etfw">#結婚式</a> <a href="https://t.co/ocOAIfnOtA">pic.twitter.com/ocOAIfnOtA</a></p>&mdash; にしこり (@nishikori223) <a href="https://twitter.com/nishikori223/status/1205263309793968129?ref_src=twsrc%5Etfw">December 12, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>