line-bot-sdk 11 WebHookをPHPで作成(備忘録)

# 最近、サーバーのアップデートと共にline-bot-sdkを 11にアップデートして、実装しようとしたらあまり良い情報に会えなかったので同じような人がおられるかも知れないと思い記録しておきます。

## 問題と原因
line-bot-sdk 11に入れ替えたら、互換性がなくなったのでWebHookの対応をしようとしたら、以前の curlでの接続ではなく、Line SDKを使おうとしたら、これが悪戦苦闘。

以前は、こちらを参考に作っていました。
　[【PHP】LINE BOTの作り方 on Messaging API](https://ysklog.net/line_bot/4551.html "【PHP】LINE BOTの作り方 on Messaging API")

さて、ハマった箇所は、'LINEBot'がline-bot-sdk v11には存在しない。多くのネットの例がLINEBotで紹介されていただ、v11では使えなかったためでした。chatGTPさんと相談しながら、GuzzleHttp\Client を使って v11に対応することができました。LINEのドキュメントもよく分からないし...

```php:.php
use LINE\LINEBot\HTTPClient\CurlHTTPClient
```

また今回、新たにDEBUGでは、ngrokを使って便利さを痛感しましたので簡単に紹介します。

## 環境その1  
さて、実装例の環境とソースです。シンプルなWebサーバー上に設置します。
```php:Linux OS
AlmaLinux 8.10
PHP 8.2.27
line-bot-sdk 11
```
WebHookのLINE Developerでの準備などは、他のサイトを参照してください。


サーバー Terminalに接続し、プロジェクトフォルダーに移動して、現時点の最新版11をline-bot-sdkのインストールします。実行後、./vendor　フォルダーにインストールされます。


```ssh:terminal
composer require linecorp/line-bot-sdk
```
ちなみに、古いバージョンをインストールするには、次のようにバージョンを記載することでLINEBotも使えましたが、どうせなら最新版と思い、今回は11にしました。

```ssh:terminal
composer remove linecorp/line-bot-sdk
composer require linecorp/line-bot-sdk:"7.*"
```

実際のコードです。LINE sdkを使うために、最初に ./vendor/autoload.phpをイポートしています。使っていないAPIもここでは指定しています。

```php:callback.php
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
// webhookの確認
require("./vendor/autoload.php");

use Monolog\Logger;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\NativeMailerHandler;
use LINE\Clients\MessagingApi\Model\ReplyMessageRequest;
use LINE\Clients\MessagingApi\Model\TextMessage;
use LINE\Clients\MessagingApi\Model\TemplateMessage;
use LINE\Clients\MessagingApi\Model\URIAction;
use LINE\Clients\MessagingApi\Model\PostbackAction;
use LINE\Clients\MessagingApi\Model\MessageAction;
use LINE\Clients\MessagingApi\Model\ButtonsTemplate;
use LINE\Constants\TemplateType;
use LINE\Constants\ActionType;
use LINE\Constants\HTTPHeader;
use LINE\Parser\EventRequestParser;
use LINE\Webhook\Model\MessageEvent;
use LINE\Webhook\Model\PostBackEvent;
use LINE\Webhook\Model\FollowEvent;
use LINE\Parser\Exception\InvalidEventRequestException;
use LINE\Parser\Exception\InvalidSignatureException;
use LINE\Webhook\Model\TextMessageContent;
use GuzzleHttp\Client;
use LINE\Clients\MessagingApi\Api\MessagingApiApi;
use LINE\Clients\MessagingApi\Configuration;
use LINE\Clients\MessagingApi\Model\PushMessageRequest;
use LINE\Constants\MessageType;

$channelSecret = '<----  channelSecret ---->';
$channelAccessToken = '< --- channelAccessToken ---->';

$client = new Client();
$config = new Configuration();
$config->setAccessToken($channelAccessToken);
$messagingApi = new MessagingApiApi(
  client: $client,
  config: $config,
);

// Monolog
// ログファイルの生成
$app = 'webhook';
$log = new Logger($app);
$rotatingFileHandler = new RotatingFileHandler("./log/webhook.log", 20, Logger::DEBUG);
$log->pushHandler($rotatingFileHandler);

try {
  $log->info('webhook開始');
  $headers = getallheaders();
  //JSONの内容を取得
  $body = file_get_contents('php://input');

  $headers = getallheaders();
  $signature = $headers['X-Line-Signature'] ?? '';

  $parsedEvents = EventRequestParser::parseEventRequest($body, $channelSecret, $signature);
  // 1つのWebhookに複数のWebhookイベントオブジェクトが含まれる場合があるため、繰り返し処理を行う。
  foreach ($parsedEvents->getEvents() as $event) {
    // ユーザ名の取得
    $userId = $event['source']['userId'];
    $log->info('ユーザID：' . $userId);
    $profile = $messagingApi->getProfile($userId);
    $log->info('ユーザー名: ' . $profile['displayName']);
    $userName = $profile['displayName'];

    $reply_message_txt = '';
    $reply_message_txt_obj = '';
    if ($event instanceof FollowEvent) { //友達登録の場合(ブロック解除でも動作する)
      // 初回だけ2秒スリープ　初回のあいさつメッセージが送信されるため少し待つ
      sleep(2);
      $reply_message_txt = $userName . "さん のユーザーIDは、" . $userId . "です。";

    } elseif ($event instanceof MessageEvent) { //メッセージ受信の場合
        $receive_message_txt = '';
      // 受信メッセージのテキストを抽出
        $receive_message = $event->getMessage();
        $receive_message_txt = trim($receive_message->getText());
        $log->info('受信メッセージ: [' . $receive_message_txt.']');

      if( strtolower($receive_message_txt) == 'id' ) {
          $reply_message_txt = $userName . "さん のユーザーIDは、" . $userId . "です。";
      } elseif($receive_message_txt == "おはよう") {
        $reply_message_txt = $userName . "さん おはようございます。出勤時間" . date('Y/m/d H:i:s') . "を記録しました。";
      } elseif($receive_message_txt == "おつかれ") {
        $reply_message_txt = $userName . "さん おつかれさまでした。退勤時間" . date('Y/m/d H:i:s') . "を記録しました。";
      } else {
        $reply_message_txt = $userName . "さん " . $receive_message_txt . "は登録されていません";
      }
 
    }

    $reply_message_txt_obj = (new TextMessage(['text' => $reply_message_txt]))->setType('text');
    $messagingApi->replyMessage(new ReplyMessageRequest(
      [
        'replyToken' => $event->getReplyToken(),
        'messages' => [
          $reply_message_txt_obj,
        ],
      ],
    ));
  }
} catch (Exception $e) {
  $log->error($e);
} finally {
  $log->info('webhook終了');
}

```
### デバッグ
LINE Developer WebHookに <サーバーURL>/callbak.php を指定し、動作を確認します。


## 環境その2  
次に、AWS上のLaravelへの実装例ですが、一旦、ローカルのdocker上で実装します。dockerに既にLaravelでサーバーがあることを前提にLine WebHookの実装について紹介します。

こちらのサイトが参考になりました。
[Laravelで超シンプルにLINE Botを作る](https://zenn.dev/tmitsuoka0423/articles/laravel-line-helloworld-04

まず、dockerを起動し、line-bot-sdkをインストールします。[php]は私のサービス名です。

```docker:Win terminal
docker compose exec php compose require linecorp/line-bot-sdk
```
composer.json に"linecorp/line-bot-sdk": "^11.0"行が追加されます。/vendorフォルダーにLINE SDKがインストールされます。( /appと同じレベル )

```docker:composer.json
    "require": {
        "php": "^8.1",
        "darkaonline/l5-swagger": "^8.5",
        "guzzlehttp/guzzle": "^7.2",
        "haruncpi/laravel-id-generator": "^1.1",
        "intervention/image": "*",
        "laravel/framework": "^10.10",
        "laravel/sanctum": "^3.3",
        "laravel/tinker": "^2.8",
        "league/fractal": "^0.20.1",
        "linecorp/line-bot-sdk": "^11.0",
        "prettus/l5-repository": "^2.9",
        "tymon/jwt-auth": "^2.0"
    },
```

次のコマンドを実行してLaravel controllerを作成します。/app/http/Contollers/LineBotController.phpが生成されます。

```docker:controller
docker compose exec php php artisan make:controller LineBotController
```
LineBotController.phpの内容を先のcallback.phpを基に下記のようにしました。アクセストークンなどは、.envに記載した方が、さらに追加で LINE用サービスを追加するには便利です。

```php:LineBotController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use GuzzleHttp\Client;
use LINE\Clients\MessagingApi\Api\MessagingApiApi;
use LINE\Clients\MessagingApi\Configuration;
use LINE\Clients\MessagingApi\Model\TextMessage;
use LINE\Clients\MessagingApi\Model\ReplyMessageRequest;
use LINE\Clients\MessagingApi\Model\PushMessageRequest;
use LINE\Webhook\Model\MessageEvent;
use LINE\Webhook\Model\PostBackEvent;
use LINE\Webhook\Model\FollowEvent;
use LINE\Webhook\Model\TextMessageContent;
use LINE\Constants\MessageType;
use LINE\Parser\EventRequestParser;

class LineBotController extends Controller
{
    protected $client;
    protected $messagingApi;
    protected $config;
    protected $accessToken;
    protected $channelSecret;

    public function __construct()
    {
        $this->client = new Client();
        $this->accessToken = '<----  your accessToken ---->';     //env('LINE_CHANNEL_ACCESS_TOKEN');
        $this->channelSecret = '<---- your channelSecret ---->';// env('LINE_CHANNEL_SECRET');

        $this->config = new Configuration();
        $this->config->setAccessToken($this->accessToken);

        $this->messagingApi = new MessagingApiApi(
            client: $this->client,
            config: $this->config,
          );
          
    }

    public function webhook(Request $request)
    {
        \Log::info('🟢 Webhook hit!');
//        \Log::info('Headers:', $request->headers->all());
//        \Log::info('Raw body:', [$request->getContent()]);
    
        $signature = $request->header('X-Line-Signature') ?? '';
        

        if (empty($signature)) {
            return response('Missing signature', 400);
        }

        $body = $request->getContent();
        if (empty($body)) {
            return response('Empty request body', 400);
        }

        // 署名を検証（LINEの署名検証を自分で実装する場合）
        if (!$this->validateSignature($body, $signature)) {
            return response('Invalid signature', 400);
        }

        // JSONデコードを実行
        
        try {
            $parsedEvents = EventRequestParser::parseEventRequest($body, $this->channelSecret, $signature);
        } catch (\Exception $e) {
            \Log::error('署名検証エラー: ' . $e->getMessage());
        }
        \Log::info('Event count: ' . count($parsedEvents->getEvents()));
        //        $parsedEvents = EventRequestParser::parseEventRequest($body, $this->channelSecret, $signature);
        
        foreach ($parsedEvents->getEvents() as $event) {
            \Log::info('event class: ' . get_class($event));
            \Log::info('LINE event type: ' . $event['type']);

            $userId = $event['source']['userId'];
            \log::info('ユーザID：' . $userId);

            $replyToken = $event['replyToken'];
            $reply_message_txt ='';

            if ($event instanceof FollowEvent) { //友達登録の場合(ブロック解除でも動作する)
                $profile = $this->messagingApi->getProfile($userId);
                $userName = $profile['displayName'];
                \log::info('ユーザー名: ' . $userName);
                    // 初回だけ2秒スリープ　初回のあいさつメッセージが送信されるため少し待つ
                sleep(2);
                // 初回の質問文
                \log::info('FollowEvent!');
                $reply_message_txt = $userName . "さん のユーザーIDは、" . $userId . "です。";
                $reply_message_txt_obj = (new TextMessage(['text' => $reply_message_txt]))->setType('text');
                $this->messagingApi->replyMessage(new ReplyMessageRequest(
                  [
                    'replyToken' => $event->getReplyToken(),
                    'messages' => [
                      $reply_message_txt_obj,
                    ],
                  ],
                ));
            
//                $this->replyMessage($replyToken, $reply_message_txt);
            } elseif ($event instanceof MessageEvent) { //メッセージ受信の場合
                $profile = $this->messagingApi->getProfile($userId);
                $userName = $profile['displayName'];
                \log::info('ユーザー名: ' . $userName);
    
                if ($event['type'] === 'message' && $event['message']['type'] === 'text') {
                    $receive_message_txt = $event['message']['text'];
                    $replyToken = $event['replyToken'];
    
                    if( strtolower($receive_message_txt) == 'id' ) {
                        $reply_message_txt = $userName . "さん のユーザーID、次のUから始まる32文字を登録してください。\n" . $userId;

                    } elseif($receive_message_txt == "おはよう") {
                      $reply_message_txt = $userName . "さん おはようございます。出勤時間" . date('Y/m/d H:i:s') . "を記録しました。";
                    } elseif($receive_message_txt == "おつかれ") {
                      $reply_message_txt = $userName . "さん おつかれさまでした。退勤時間" . date('Y/m/d H:i:s') . "を記録しました。";
                    } else {
                          $reply_message_txt = $userName . "さん " . $receive_message_txt . "は登録されていません";
                    }

                    $reply_message_txt_obj = (new TextMessage(['text' => $reply_message_txt]))->setType('text');
                    // 返信メッセージを作成
                    $this->messagingApi->replyMessage(new ReplyMessageRequest(
                    [
                        'replyToken' => $event->getReplyToken(),
                        'messages' => [
                          $reply_message_txt_obj,
                        ],
                      ],
                  ));
//                    $this->replyMessage($replyToken, $reply_message_txt);
                }
    
            }
        }

//        return response('OK', 200);
    }

    private function validateSignature($body, $signature)
    {
        // 署名検証のロジックを実装（LINE SDKがなくても自分で署名検証を行う）
        $hash = hash_hmac('sha256', $body, $this->channelSecret, true);
        $calculatedSignature = base64_encode($hash);
        
        return hash_equals($calculatedSignature, $signature);
    }
}

```

次に、/routes/api.phpにapiを追加します。LINE WebHookには、<サーバー>/api/webhook と記載します。
``` php:api.php
Route::post('webhook',[LineBotController::class,('webhook']);
```

docker サービスを開始
``` docker:terminal
docker-compose up -d --build
```

## いよいよDEBUGです。
そこで覚えたのが ngrok です。サイトでアカウントを作成し、インストール、authtokenを取得して、設定するだけ。これとPOSTMAN、ログファイル /storage/logs/laravel.logでDEBUG環境も充実して、最近は楽になったとつくづく実感しました。


[postman] (https://www.postman.com/)

[ngrok] (https://dashboard.ngrok.com/get-started/setup/windows)

Windows コマンド画面で、ngrokを起動。画面に表示される。Forwarding のURLを使ってLINE WebHookのDEBUGが可能です。とても便利。

``` win:cmd
ngrok http 8080
```

DEBUGが終わったら、AWSに同期。以上!

lind-bot-sdk 11 の実装例です。参考になりましたら幸いです。


# おまけ、LINE Message API サービス Laravel例

/app/Services/LinePostService.phpを作成し、Webサービスから LINEメッセージを送信するサービスを追加しました。下記は参考例です。様々なLINEメッセージを送信するAPIを追加できますが、メッセージ送信部分のみを記載しました。

``` Laravel:LinePostService.php
<?php

namespace App\Services;

use Illuminate\Http\Request;

use App\Http\Requests\LinePostRequest;
use GuzzleHttp\Client;
use LINE\Clients\MessagingApi\Api\MessagingApiApi;
use LINE\Clients\MessagingApi\Configuration;
use LINE\Clients\MessagingApi\Model\TextMessage;
use LINE\Clients\MessagingApi\Model\ReplyMessageRequest;
use LINE\Clients\MessagingApi\Model\PushMessageRequest;
use App\Models\Linelog;

class LinePostService
{
    protected $client;
    protected $messagingApi;
    protected $config;

    protected $accessToken;
    protected $channelSecret;

    public function __construct()
    {
        $LINE_PUSH_URL = 'https://api.line.me/v2/bot/';

        $this->client = new Client([
            'base_uri' => $LINE_PUSH_URL,
        ]);
        $this->accessToken = '<---- your accessToken ---->'; //env('LINE_CHANNEL_ACCESS_TOKEN');
        $this->channelSecret = '<---- your channelSecret ---->';// env('LINE_CHANNEL_SECRET');

        $this->config = new Configuration();
        $this->config->setAccessToken($this->accessToken);

        $this->messagingApi = new MessagingApiApi(
            client: $this->client,
            config: $this->config,
          );

    }
    // DBにLine アクティビティを記録
    public function logLine($request)
    {
//        \Log::info('🟢 LinePostService log!');
        Linelog::create([
            'company_id' => $request['company_id'],
            'to_userId' => $request['to_userId'],
            'to_lineId' => $request['to_lineId'],
            'msg_type' => $request['msg_type'],
            'message' => $request['message'],
        ]);
    }
    
    // LINE Message送信用 API 
    public function sendMessage($data)
    {
//        \Log::info('🟢 LinePostService sendMessage hit!');
//        \Log::info('LinePostService sendMessage ',$data);

        // LineIDのチェック U で始まる32文字
        $to = $data['to_lineId'];
        if (preg_match('/^U[a-f0-9]{32}$/i', $to)) {
//            \Log::info('sendTo: '.$to);
        }
        else
        {
//            \Log::info('Invalid LineID: '.$to);
            return response(['message' => "Invalid LineID" ], 400);
        }

        $messageText = '['.$data['company_name']."] \n".$data['message'];

        $this->logLine($data);

        try{
            $response = $this->client->post('message/push', [
                'headers' => [
                    'Authorization' => 'Bearer ' . $this->accessToken,
                    'Content-Type' => 'application/json; charset=utf-8'
                ],
                'json' => [
                    'to' => $to,
                    'messages' => [
                        [
                            'type' => 'text',
                            'text' => $messageText,
                        ]
                    ]
                ]
            ]);
        } catch (\Exception $e) {
            \Log::info('LinePost Error sendTo:'.$to);
            \Log::info($e);
        }

        return response(['message' => "OK" ], 200);

    }
}
```