Line Messaging API で画像つき通報システムをつくる(神戸市に影響されて)

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、私は(ほぼ夏だけですが)各地を回っていろいろな「珍スポット」を巡るのが大好きなんですが、そんな私が「へぇ、これ便利なシステムだな❗」と思ったニュースがありました。

それが・・・・・・

道路や公園の不具合を LINE で(写真 & 位置情報つきで)通報できる

公共システムです。

📝 参考ページ: 「道路や公園の不具合をLINEで教えて」神戸市が新サービス発表

つまり、もし何かを発見してもいちいち役所まで電話したり、行かなくても神戸市に「こんな不具合があるよ」と教えることができるというサービスです。

ちなみに私は(完全趣味で) 街角コレクション というサイトを運営していて、ありがたいことに多数の写真をユーザーさんからご投稿いただいたことがあるのですが、このシステムを使えばユーザーさんがより便利になるんじゃないかと思い、一度テストで作ってみることにしました。

そこで❗

今回は「LINE Messaging API + Laravel」でシンプル版の「写真 & 位置情報」が投稿できる機能をつくってみることにします。

ぜひ何かの参考になりましたら嬉しいです。😄✨

※ 街角コレクションの 「街角コレクション・写真投稿システム」はこちら からお友達登録できます!

「友人がくれた
お土産のクラフトビール
ウマウマでした🍻」

開発環境: Laravel 10.x

前提として

今回はLINE Messaging APIを使って実装するので、LINE Developers に登録している必要があります。

まだの方は以下をご覧になって先に登録しておいてください。

📝 参考ページ: LINE Developersにアカウントを作る

※ ご注意: 昔の記事ですので、ちょっと違っている可能性があります。

では、今回も楽しんでやっていきましょう❗

LINE Developers で「チャネル(ボット)」をつくる

 

まずコンソールにある「Admin」からプロバイダーを選択し、「新規チャネル作成」をクリックします。

以下のようなポップアップが表示されるので「Messaging API」をクリック。

ページ移動した先にあるフォームに必要な項目を入力&チェックします。

作成ボタンをクリック。

すると、以下のように登録が完了するので以下2つの場所にある2つの「トークン(パスワードのようなもの)」を取得してください。

  • コンソール → Messaging API設定 → チャネルアクセストークン
  • コンソール → チャネル基本設定 → チャネルシークレット

そして、それらのトークンはLaravel.envに以下のようにセットしておきましょう。

.env

LINE_POST_PHOTO_ACCESS_TOKEN=*****************************
LINE_POST_PHOTO_SECRET=****************

※ アクセストークンは長ーい文字列でした

ウェブフックを有効にする

また、今回は「ウェブフック(メッセージを受信したら、その内容をウェブサイト側に送信してくれる機能)」を有効にしておく必要があります。

コンソール → Messaging API設定」へ移動してURLを入力します。
なお、今回は「https://*******/line_webhook/post_photo」に設定し、「Webhookの利用」にチェックをいれてください。

なお、ローカルでウェブフックを開発する場合、ngrok(えんぐろっく)がめちゃくちゃ便利です。ngrokについては「おまけ: ngrok について」をご覧ください。

もしngrokURLをセットする場合は「https://****-***-***-***-***.ngrok-free.app/line_webhook/post_photo」のようになります。

そして、忘れてはいけないのがLaravelCsrfトークンチェックの解除です。

CSRFトークンチェック」は、セキュリティ向上のためLaravelでは標準搭載されていますが、ウェブフックにはそのトークンを含めることができないので、ウェブフックのURLだけはこのチェックを外しておく必要があります。

app/Http/Middleware/VerifyCsrfToken.php

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array<int, string>
     */
    protected $except = [
        'line_webhook/*', // 👈 ここを追加しました
    ];
}

応答メッセージを無効にする

初期状態でLINE Messaging APIは自動で固定されたメッセージを返信するようになっていますが、これは不要なので無効にしておきましょう。

コンソール → Messaging API設定」の「応答メッセージ」と書かれている場所にある「編集」リンクをクリックします。

すると、以下のようなチェックボックスが表示されるので Webhook 以外を無効にします。

これでLINE Developerでの作業は完了です。

パッケージをインストールする

実際にプログラムする前に、LINEPHP用のパッケージを用意してくれているのでこれをインストールおきます。

以下のコマンドを実行してください。

composer require linecorp/line-bot-sdk

モデル&マイグレーションをつくる

では、ここからはメインのプログラム部分になります。
まずはデータベース周りから作っていきましょう。

以下のコマンドを実行してください。

php artisan make:model UserPost -m

すると、モデルとマイグレーションのファイル2つが作成されるので、中身を以下のようにします。

app/Models/UserPost.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class UserPost extends Model
{
    use HasFactory;

    protected $appends = [
        'file_path',
    ];

    // Accessor
    public function getFilePathAttribute()
    {
        return storage_path('app/public/post_photos/' . $this->filename);
    }
}

database/migrations/****_**_**_******_create_user_posts_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('user_posts', function (Blueprint $table) {
            $table->id();
            $table->string('line_user_id')->comment('LINE ユーザー ID');
            $table->string('filename')->comment('ファイル名');
            $table->double('latitude', 9, 6)->nullable()->comment('緯度');
            $table->double('longitude', 9, 6)->nullable()->comment('経度');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('user_posts');
    }
};

では、この状態でテーブルを追加してみましょう。
以下のコマンドを実行してください。

php artisan migrate

すると、実際のテーブはこうなりました。

メール送信部分をつくる

そして、後で使うことになるメール送信部分をつくっておきます。
メールを送信するのは「写真&位置情報の投稿が完了したとき」です。

では、以下のコマンドを実行してください。

php artisan make:mail UserPosted

すると、ファイルが作成されるので中身を以下のように変更します。

app/Mail/UserPosted.php

<?php

namespace App\Mail;

use App\Models\UserPost;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class UserPosted extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Create a new message instance.
     */
    public function __construct(private UserPost $user_post)
    {}

    /**
     * Get the message envelope.
     */
    public function envelope(): Envelope
    {
        $from = 'no-reply@example.com';

        return new Envelope(
            from: $from,
            subject: 'ユーザーから写真&位置情報の投稿がありました',
        );
    }

    /**
     * Get the message content definition.
     */
    public function content(): Content
    {
        $latitude = $this->user_post->latitude;
        $longitude = $this->user_post->longitude;
        $google_maps_url = 'https://maps.google.com/maps?z=15&t=k&q=loc:'. urlencode($latitude .','. $longitude);

        return new Content(
            view: 'emails.user_posted',
            with: [
                'latitude' => $latitude,
                'longitude' => $longitude,
                'google_maps_url' => $google_maps_url
            ]
        );
    }

    /**
     * Get the attachments for the message.
     *
     * @return array<int, \Illuminate\Mail\Mailables\Attachment>
     */
    public function attachments(): array
    {
        return [
            Attachment::fromPath($this->user_post->file_path),
        ];
    }
}

そして、メール文面のビューも作成します。

php artisan make:view emails.user_posted

中身は以下のようにしてください。

resources/views/emails/user_posted.blade.php

ユーザーさんが写真&位置情報を送ってくれました。<br>

感謝してチェックするように!<br><br>

地図リンク: <a href="{{ $google_maps_url }}" target="_blank">{{ $longitude }},{{ $latitude }}</a>

コントローラーをつくる

次にコントローラーを作成するので、以下のコマンドを実行してください。

php artisan make:controller LinePostPhotoWebhookController

すると、ファイルが作成されるので中身を以下のように変更します。

app/Http/Controllers/LinePostPhotoWebhookController.php

<?php

namespace App\Http\Controllers;

use App\Mail\UserPosted;
use App\Models\UserPost;
use Illuminate\Http\Request;
use GuzzleHttp\Client as GuzzleClient;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use LINE\Clients\MessagingApi\Configuration;
use LINE\Clients\MessagingApi\Api\MessagingApiApi;
use LINE\Clients\MessagingApi\Api\MessagingApiBlobApi;
use LINE\Clients\MessagingApi\Model\ReplyMessageRequest;
use LINE\Clients\MessagingApi\Model\LocationAction;
use LINE\Clients\MessagingApi\Model\QuickReply;
use LINE\Clients\MessagingApi\Model\QuickReplyItem;
use LINE\Clients\MessagingApi\Model\TextMessage;
use LINE\Parser\EventRequestParser;
use LINE\Webhook\Model\MessageEvent;
use LINE\Webhook\Model\ImageMessageContent;
use LINE\Webhook\Model\LocationMessageContent;
use LINE\Webhook\Model\FollowEvent;
use LINE\Constants\ActionType;
use LINE\Constants\MessageType;
use LINE\Constants\TemplateType;

class LinePostPhotoWebhookController extends Controller
{
    const EXPIRATION_MINUTES = 30; // 画像が送信されてから位置情報が送信されるまでの有効時間(分)

    private $api = null;
    private $blob_api = null;

    public function __construct()
    {
        // ↓↓↓ 本来は config/services.php から取得するべきです
        $this->channel_secret = env('LINE_POST_PHOTO_SECRET');
        $this->access_token = env('LINE_POST_PHOTO_ACCESS_TOKEN');
    }

    public function receive(Request $request)
    {
        $request_body = $request->getContent();
        $hash = hash_hmac('sha256', $request_body, $this->channel_secret, true);
        $signature = base64_encode($hash);

        if($signature === $request->header('X-Line-Signature')) {

            $client = new GuzzleClient;
            $config = new Configuration();
            $config->setAccessToken($this->access_token);

            $this->api = new MessagingApiApi(
                client: $client,
                config: $config,
            );
            $this->blob_api = new MessagingApiBlobApi(
                client: $client,
                config: $config,
            );

            try {

                $event_parser = EventRequestParser::parseEventRequest($request_body, $this->channel_secret, $signature);

                foreach($event_parser->getEvents() as $event) {

                    if($event instanceof FollowEvent) { // お友達の登録したとき

                        $this->followEvent($event);

                    } else if ($event instanceof MessageEvent) { // メッセージを受信したとき

                        $message = $event->getMessage();

                        if ($message instanceof ImageMessageContent) { // 写真が送信されたとき

                            $this->imageMessageEvent($event, $message);

                        } else if ($message instanceof LocationMessageContent) { // 位置情報が送信されたとき

                            $this->locationMessageEvent($event, $message);

                        }

                    }

                }

            } catch (\Exception $e) {}

        } else {

            abort(404);

        }
    }

    // イベント
    private function followEvent(FollowEvent $event) // お友達登になったとき
    {
        $reply_token = $event->getReplyToken();
        $text = "写真投稿サービスに登録していただいてありがとうございます!\n\n写真を送信すると位置情報が選択できるようになり、写真と位置情報が2つ揃った時点で投稿が完了します。\n\nぜひよろしくお願いいたします!";

        $this->replyTextMessage($text, $reply_token);
    }

    private function imageMessageEvent(MessageEvent $event, ImageMessageContent $message) // 写真が送信されたとき
    {
        $message_id = $message->getId();
        $sfo = $this->blob_api->getMessageContent($message_id);
        $image_data = $sfo->fread($sfo->getSize());

        if($image_data) {

            $filename = Str::uuid() .'.jpg';
            $save_dir = storage_path('app/public/post_photos');

            if(! file_exists($save_dir)) {

                mkdir($save_dir, 0777, true); // フォルダがなければ作成

            };

            file_put_contents($save_dir .'/'. $filename, $image_data);

            // 画像の場合は必ず新規作成
            $line_user_id = $this->getLineUserId($event);
            $user_post = new UserPost;
            $user_post->line_user_id = $line_user_id;
            $user_post->filename = $filename;
            $user_post->save();

            $this->quickReplyForLocation($event);

        }
    }

    private function locationMessageEvent(MessageEvent $event, LocationMessageContent $message) // 位置情報が送信されたとき
    {
        $line_user_id = $this->getLineUserId($event);
        $reply_token = $event->getReplyToken();
        $base_dt = now()->subMinutes(self::EXPIRATION_MINUTES);

        // 位置情報の場合は、最新の投稿を取得して更新する
        $user_post = UserPost::query()
            ->where('line_user_id', $line_user_id)
            ->whereNull('latitude')
            ->whereNull('longitude')
            ->where('created_at', '>=', $base_dt) // 有効期限内のものを対象
            ->orderBy('created_at', 'desc')
            ->first();

        if(! is_null($user_post)) {

            $user_post->latitude = $message->getLatitude();
            $user_post->longitude = $message->getLongitude();
            $user_post->save();

            $text = "位置情報が送信されました。\n\n写真と位置情報が揃いましたので、投稿が完了しました。\n\nありがとうございました!";
            $this->replyTextMessage($text, $reply_token);

            $this->sendEmailToAdmin($user_post); // 管理者にメールを送信

        } else {

            // ホントはここでエラーメッセージを返信(省略)

        }
    }

    // その他
    private function getLineUserId(MessageEvent $event)
    {
        $source = $event->getSource();

        return $source->getUserId();
    }

    private function replyTextMessage(string $text, string $reply_token) // テキストメッセージの返信
    {
        $text_message = new TextMessage(['text' => $text]);

        $this->api->replyMessage(new ReplyMessageRequest([
            'replyToken' => $reply_token,
            'messages' => [
                $text_message->setType('text'),
            ],
        ]));
    }

    private function quickReplyForLocation(MessageEvent $event)
    {
        $reply_token = $event->getReplyToken();
        $quick_reply = new QuickReply([
            'items' => [
                new QuickReplyItem([
                    'type' => 'action',
                    'action' => new LocationAction([
                        'type' => ActionType::LOCATION,
                        'label' => '位置情報を送信する',
                    ]),
                ]),
            ]
        ]);

        $text_message = new TextMessage([
            'text' => 'ボタンをクリックして位置情報を選択してください(有効時間は'. self::EXPIRATION_MINUTES .'分です)',
            'type' => MessageType::TEXT,
            'quickReply' => $quick_reply,
        ]);
        $this->api->replyMessage(new ReplyMessageRequest([
            'replyToken' => $reply_token,
            'messages' => [
                $text_message->setType('text'),
            ],
        ]));
    }

    private function sendEmailToAdmin(UserPost $user_post)
    {
        $to = 'admin@example.com';

        Mail::to($to)->send(new UserPosted($user_post));
    }
}

ちなみに、今回インストールしたパッケージ「linecorp/line-bot-sdk」はバージョン9.3になっていたのですが、どうやら以前のバージョンからは変更されて以下のようになっていました。

  • 以前のメソッド名やそもそも別クラスで呼び出さないといけなくなってしまったため、昔のコードが使えない…😫
  • しかも、新しい書き方のサンプルコードはまだほとんどない…😫

一個一個返り値の中身をlogger()で出してチェックするなど、ちょっと調べるのたいへんでした(笑)

ルートをつくる

では、最後にルートをつくります。

routes/web.php

use App\Http\Controllers\LinePostPhotoWebhookController;

// 省略

Route::post('line_webhook/post_photo', [LinePostPhotoWebhookController::class, 'receive']);

なお、これで作業は完了です。
お疲れ様でした。😄👍✨

テストしてみる

では実際にテストしてみましょう❗

まず、Line Developersで作成したチャネルに表示されているQRコードをLINEで読み込んでお友達登録します。

すると、自動で以下のように返信がありました。
まずは成功です😄

では、続いて写真を送信してみましょう。(先日、台湾から来たクラスメートに会ったときの京都タワーです。幻想的でした👍)

すると、これにも自動で返信があり、位置情報の登録を促すボタンが表示されました。

そこで、このボタンをタップして位置情報を送信してみると・・・・・・

はい❗
完了メッセージがとどきました。

ここまでは成功です😄✨

では、メールが届いているかチェックしておきましょう。

どうなったでしょうか・・・・・・

はい❗
うまくメールで画像&位置情報が送信されてきました。

すべて成功です。✨😄👍

おまけ: ngrok について

ngrokはいわゆる「トンネル」ができるサービスで、以下の流れでローカル環境なのに、ウェブフックが受信できるようになります。

  1. LINE に受信がある
  2. LINE がウェブフックを(ngrok へ)送信
  3. ngrok がいいカンジにウェブフックの内容をローカルへ送信
  4. 擬似的にウェブフックを受け取ることができる

しかもHTTPリクエストが月間 100,000 回まで無料という開発用ならほぼ無料でいけるという太っ腹です。(2023.11.17 現在)

私はLinuxを使っている関係上インストール方法はここではご紹介しませんが、以下のページから各OSのインストール方法がわかりますので、ぜひ試してみてください。

📝 参考ページ: Install ngrok

企業様へのご提案

今回の技術を使うと、例えば「工場内の修理すべき場所を報告する」とか「出先のお客様のいる場所で起こった不具合の通知」など、いろいろなシステムとして使えると思います。

また、今回は写真と位置情報だけでしたが他にも以下のような情報も取得することができます。

  • 日時を選択する
  • カメラを自動で起動する
  • 電話番号を開く
  • 特定の URL を開く

ぜひこういった機能を使って社内システムや顧客向けサービスを開発したい場合はぜひお気軽にお問い合わせからご連絡ください。

お待ちしております。😄✨

おわりに

ということで、今回は「LINE Messaging API + Laravel」で写真&位置情報の通知システムをつくってみました。

LINEはもはやインフラといっていいぐらいのユーザーがいますので、これを使って何か便利なサービスを作ってみてくださいね。

また、今回は実装していませんが、今回の記事の発端になった神戸市の通報システムではメニューが表示されていてそこから通報の他にもいろいろなことができるようでした。

今後はこんなメニューなども使ってみたいですね。

ぜひ皆さんもやってみてください。

ではでは〜❗

「結局、京都は
滞在したのは、たった1時間ほど
でした(笑)楽しかった🍻」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ