LINE BOT APIのreplyMessageでたまにInvalid reply tokenが発生する

## 概要
[line-bot-sdk-java](https://github.com/line/line-bot-sdk-java)を使ってLINE BOTを作ったが、たまにInvalid reply tokenが発生し返信できないことがある。

この記事ではJavaの場合の解決方法を書くが、他の言語でも原因は同じである。
ちなみに、Herokuの無料hobbyプランを使っている。

## エラー内容

> ERROR 4 --- [io-33152-exec-3] c.l.b.s.b.s.LineMessageHandlerSupport    : InvocationTargetException occurred.
Caused by: java.lang.RuntimeException: java.util.concurrent.ExecutionException: com.linecorp.bot.client.exception.BadRequestException: Invalid reply token : ErrorResponse(requestId=xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, message=Invalid reply token, details=[])

> Caused by: java.util.concurrent.ExecutionException: com.linecorp.bot.client.exception.BadRequestException: Invalid reply token : ErrorResponse(requestId=xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, message=Invalid reply token, details=[])


> Caused by: com.linecorp.bot.client.exception.BadRequestException: Invalid reply token : ErrorResponse(requestId=xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, message=Invalid reply token, details=[])

## 原因
公式のドキュメントには以下のように書いてある。
> 応答トークンは一定の期間が経過すると無効になるため、メッセージを受信したらすぐに応答を返す必要があります。応答トークンは1回のみ使用できます。
[応答メッセージを送る](https://developers.line.biz/ja/reference/messaging-api/#send-reply-message)

LINE BOTの[replyMessage(応答メッセージ)](https://developers.line.biz/ja/docs/messaging-api/sending-messages/#methods-of-sending-message)は、送信されたメッセージ１つに付きreply tokenが発行され、それを使ってBOTが返信するという仕組みになっている。そして、このreply tokenは一定期間経過すると無効になり、そうするとInvalid reply tokenが発生し返信ができない、というのが原因のようである。

以下の記事で、このtokenがどのくらいの時間で無効になるのか検証されているが、30秒らしい。

- [LINE Messaging API の replyToken は何秒で無効になるのか検証](https://blog.morugu.com/entry/check-line-reply-token-expire)
- [たまにInvalid reply token](https://www.line-community.me/ja/question/5c661cf5851f74bc0b3203b8?answerid=5c7d22d0851f74bc0b32372b)

## エラーが起こる条件
原因はわかったが、なぜこのエラーが発生するのか。

このBOTはHerokuの無料・ホビーのプランで運用している。
https://jp.heroku.com/pricing

この無料のプランは、インスタンスに30分アクセスがない場合自動でsleep状態になる。
そして再度アクセスがあった場合に、起動し処理を実行する。

sleep状態であっても、受けたリクエストに対する処理は実行してくれるのだが、sleepからrunning状態になるのに時間がかかるため、sleep開けの初回リクエストに対するレスポンスは、通常よりも少し時間がかかってしまう。

つまり今回のエラーは、LINEでユーザーがBOTにメッセージを送った際、Herokuがsleep状態であるとHerokuが起動する時間がかかるため、その分BOTからの返信に時間がかかる。その間にreply tokenの有効期限が過ぎてしまうので、Invalid reply tokenが発生するということになる。

## 対策
対策には２つある。

### 1. Herokuを有料プランにする
有料プランにすればsleep状態にはならないので、お金で解決する。
https://jp.heroku.com/pricing

### 2. pushMessageを使う
Invalid reply tokenが発生したら、pushMessageを使って再送信する方法で解決する。

LINE BOTでメッセージを送信する方法には、大きく分けて、先述した[replyMessage(応答メッセージ)](https://developers.line.biz/ja/docs/messaging-api/sending-messages/#methods-of-sending-message)と、この[pushMessage(プッシュメッセージ)](https://developers.line.biz/ja/docs/messaging-api/sending-messages/#push-messages)の２つがある。

replyMessageはreply tokenが必要だが、pushMessageにはreply tokenは不要なので、任意のタイミングでメッセージを送信することができる。

つまり、replyMessageでInvalid reply tokenが発生したら、pushMessageを使ってリトライする、という方法で解決する。

ちなみにpushMesasgeにはreply tokenは不要だが、グループに返信する場合にはgroupId、個別ユーザーへの場合はuserIdが必要なので、送信する際にはいずれかを指定すること。

```java:KitchenSinkController.java
@Slf4j
@LineMessageHandler
public class KitchenSinkController {
    // 一部省略

    // replyMessageでの返信
    private void replyMessage(String replyToken, List<Message> replyMessage) throws Exception {
        try {
            // 返信処理
            BotApiResponse apiResponse = lineMessagingClient
                    .replyMessage(new ReplyMessage(replyToken, replyMessage, this.notificationDisabled))
                    .get();
            log.info("Sent messages: {}", apiResponse);
        } catch (InterruptedException | ExecutionException e) {
            //throw new RuntimeException(e);
            // Invalid reply tokenが発生した場合ここでcatchされるので、pushMessage処理を実行する。

            // toにはuserIdもしくはgroupIdを入れる(userId、groupIdをプロパティにsetする処理は省略)
            String to = (this.groupId.isEmpty()) ? this.userId : this.groupId;
            pushMessage(to, replyMessage);
        }
    }

    // pushMessageでの送信。toにはuserIdもしくはgroupIdを入れる
    private void pushMessage(String to, List<Message> replyMessage) throws Exception {
        try {
            BotApiResponse apiResponse = lineMessagingClient
                    .pushMessage(new PushMessage(to, replyMessage, this.notificationDisabled))
                    .get();
            log.info("Sent push messages to: {}", to);
            log.info("Sent push messages: {}", apiResponse);
        } catch (InterruptedException | ExecutionException e) {
            // pushMesageでもだめならExceptionを投げる
            throw new RuntimeException(e);
        }
    }
}
```

ちなみにuserIdは以下で取得できる。

```java
String userId = event.getSource().getUserId();
```

groupIdはグループチャットでない場合は存在しないため、取得方法が少し面倒だがこんな感じ。

```java
String groupId;
if (event.getSource() instanceof GroupSource) {
    groupId = ((GroupSource) event.getSource()).getGroupId();
} else {
    groupId = "";
}
```

pushMessageの詳細については以下の公式ドキュメントを参照。

- https://developers.line.biz/ja/docs/messaging-api/sending-messages/#methods-of-sending-message
- https://developers.line.biz/ja/reference/messaging-api/#send-reply-message

## まとめ
この記事ではJavaの解決策を書いたが、他の言語でも原因は同じため、pushMessageでリトライするようにすれば解決できる。

## 関連
- [line-bot-sdk-javaを使ってSpring bootでLINE Botを作る](https://qiita.com/Esfahan/items/25d74247778c87454634)
- [Spring boot + JPA + Flyway on herokuでLINE Botを作る](https://qiita.com/Esfahan/items/854fe119f90c6abe37d6)