Next.jsとNestjsでLINE Messaging APIアカウント連携

komosyu
Nestjs

目次

  1. はじめに
  2. LINE Messaging API とはどんなもの?
  3. 今回やること
  4. 全体の流れ
  5. 1.LINE Developer Console
  6. 2.Webhook 用の URL を SSL 化
  7. 3.チャネルアクセストークン
  8. 4.Webhook で follow イベントから LINE ユーザー ID 取得
  9. 5.LINE ユーザー ID から連携トークンを生成
  10. 6.自社のログイン画面 URL を送信
  11. 7.ログインしてもらって自社のユーザー ID を取得
  12. 8.自社のユーザー ID を保存
  13. 9.Webhook で accountLink イベントから LINE ユーザー ID 取得
  14. 10.自社の DB に LINE ユーザー ID を保存
  15. 11.自社サービスで購入イベント発生時に公式 LINE アカウントから通知
  16. 参考
  17. さいごに

はじめに

前回 LINE ログインを Next.js と Nestjs で実装する方法についての記事を書いたのですが、今回はまた別で LINE Messaging API の実装にトライしているので備忘録としてまとめてみます。
LINE ログインの実装と比較してみると、結構複雑というかできることが多いんですよね。
今回は LINE Messaging API でできる基礎的な内容とアカウント連携という自社のユーザー ID と INE のユーザー ID を紐づける方法をまとめていきたいと思います。

LINE Messaging API とはどんなもの?

まず LINE Messaging API がどんなものかというところから。
これはふだんから、いろんな企業の公式アカウントからメッセージを受け取ったりすることがあると思いますので、イメージがつきやすいと思います。
定期的にクーポンが送られてきたり新店舗オープンのメッセージが送られてくるプッシュメッセージメッセージなんかがよくあります。

今回やること

公式 LINE アカウントからメッセージを送るだけだと簡単過ぎてしまうので、今回は公式 LINE アカウントに友だち登録したユーザーと自社サービスのユーザーを紐づける。
いわゆるアカウント連携みたいなところをやっていきたいと思います。
このアカウント連携ができると、たとえば自社サービスで商品の購入をした際に LINE から到着予定だったり商品詳細などの情報をユーザーに対して簡単に送ることができたりします。

全体の流れ

  • 1.LINE Developer Console
  • 2.Webhook 用の URL を SSL 化
  • 3.チャネルアクセストークン
  • 4.Webhook で follow イベントから LINE ユーザー ID 取得
  • 5.LINE ユーザー ID から連携トークンを生成
  • 6.自社のログイン画面 URL を送信
  • 7.ログインしてもらって自社のユーザー ID を取得
  • 8.自社のユーザー ID を保存
  • 9.Webhook で accountLink イベントから LINE ユーザー ID 取得
  • 10.自社の DB に LINE ユーザー ID を保存
  • 11.自社サービスで購入イベント発生時に公式 LINE アカウントから通知

意外とありますな...

1.LINE Developer Console

まずは LINE の設定から。

LINE ログインの時と同じようにプロバイダーからチャネルを作成するのですが、この際に LINE Messaging API を指定します。

プロバイダーの作り方がわからなかったら、前回の LINE ログインについての記事を見てみてください。

今回は新規チャネル作成から LINE LINE Messaging API のチャネルを作るところから。

LINE Developer ConsoleでLINE Messaging APIのチャネル作成

設定するチャネルの中身はこんな感じで、まあ LINE ログインの時とそんなには変わりません。
つまずくところもないと思うので必要箇所を埋めて設定していきましょう。

LINE Developer ConsoleでLINE Messaging APIのチャネル設定

一通り入力が完了して「作成」のボタンをクリックするとこんなモーダルが出てきます。

LINE Developer ConsoleでLINE Messaging APIのチャネル確認モーダル

ここに書いてあるように、今作成した LINE Messaging API に紐づいた公式 LINE アカウント作成されます。
実際の開発でも、この公式 LINE アカウントを通じてイベントを受け取ったりメッセージを送ったりします。

2.Webhook 用の URL を SSL 化

今回は LINE Messaging API がメインのテーマなので Next.js や Nestjs の環境構築の話はスキップさせていただきますね。

それで公式 LINE アカウントにユーザーが友だち登録をしたりブロックしたりしたタイミングでイベントを受け取ることができるエンドポイントを先ほどの LINE Developer Console で Webhook として受け取ることができるのです。

今回はアカウント連携をしたいので、ユーザーが友だち登録をしてくれたタイミングで裏側で Webhook から送られてきたデータをもとにごちゃごちゃと処理をしていく必要がありますので、この webhok を登録していきましょう。

「Messaging API 設定」というタブをクリックすると、「Webhook 設定」という項目が出て来ますので、ここにその URL を登録していきます。

LINE Developer ConsoleでLINE Messaging APIのWebhook設定

しかし、よく見てみると「有効な HTTPS URL を入力してください」と書いてありますね。
開発中だからhttp://localhostなんだけどどうしたもんか。
LINE ログインのコールバック URL はそんなの無視でよかったのですが、ちょっと面倒くさいですね。

調べてみると開発中の URL を SSL 化してくれるlocaltunnelというサービスがあるみたいなので、これを使わせていただくことに。
使い方は非常に簡単です。

  • 1.yarn add localtunnelコマンドをターミナルで入力してパッケージをインストール
  • 2.npx lt --port 4000 --subdomain messaging-apiで localhost で動いているポート番号と固定したいドメイン名を指定
  • 3.するとこんな感じyour url is: https://messaging-api.loca.ltで SSL 化された URL が出てきます

同じようなことができるというngrokでは URL を固定するのにお金がかかるということなので無料だと毎回ランダムな URL が出力されてしまうみたいなので、こういったツールは非常に助かりますね。

はい。そしたら出来上がった SSL 化された URL を LINE Developer Console で設定していきましょう。

LINE から送られてくるイベントを受け取るエンドポイントはhttps://messaging-api.loca.lt/line/Webhookとしましょう。

3.チャネルアクセストークン

LINE Developer Console で設定する必要があるものがもうひとつあります。

それがチャネルアクセストークンと呼ばれるものです。

これは何種類かあってシステムに応じていずれかを選択すればいいと思うのですが、今回は長期のチャネルアクセストークンというのが管理画面から「発行」クリックするだけで発行できるので、これで済ませましょう。

  • 任意の有効期間を指定できるチャネルアクセストークン
  • ステートレスチャネルアクセストークン
  • 短期のチャネルアクセストークン
  • 長期のチャネルアクセストークン

このチャネルアクセストークンと呼ばれるものはどう使われるのかを公式ドキュメントで調べてみると「チャネルアクセストークンは、例えばアプリケーションが Messaging API チャネルを使用するときに、チャネルを使用する権限を持っているかどうかを確認する際に用います。」と記載されていました。

まあ、その名前の通りにアプリケーションから LINE プラットフォームのチャネルにアクセスするのに必要なんだなと覚えておきましょう。

4.Webhook で follow イベントから LINE ユーザー ID 取得

さあ、それでは実際に Nestjs にコードを書いていきましょう!!

LINE Developer Console で指定したエンドポイントの/line/Webhookで LINE からのイベントを受け取れるようにします。

が、ここでリクエストがちゃんと LINE プラットフォームから送られて来たものかを確認する必要がありますので署名の検証という処理も同時に行なっていく必要があります。

リクエストが LINE プラットフォームから送られたことを確認するために、ボットサーバーでリクエストヘッダーのx-line-signatureに含まれる署名を検証します。
1.チャネルシークレットを秘密鍵として、HMAC-SHA256 アルゴリズムを使用してリクエストボディのダイジェスト値を取得します。
2.ダイジェスト値を Base64 エンコードした値と、リクエストヘッダーのx-line-signatureに含まれる署名が一致することを確認します。

なので、Controller,Service の他に Middleware も作成していきます。

この辺りを簡単に処理してくれる@line/bot-sdkという公式で用意されている SDK がありますので、まずはこのインストールから。

yarn add @line/bot-sdk --save

まずは Controller(/line/line.controller.ts) から

import {
  Controller,
  Post,
  RawBodyRequest,
  Req,
  Res,
} from '@nestjs/common';

@Controller('line')
export class LineController {
    constructor(private service: LineService){}

    @Post('Webhook') // /line/webhookのエンドポイントを作る
    public async Webhook(
        @Req() req: RawBodyRequest<Request>,
        @Res() res: Response,
    ) {
        await this.service.Webhook(req, res);
    }
}

続いて Service(/line/line.service.ts)

export class LineService {
  constructor() {}

  public async Webhook(@Req() req, @Res() res) {
    const lineSignature = req.headers['x-line-signature']; // 署名
    const channelSecret = process.env.LINE_MESSAGE_API_CHANNEL_SECRET; // LINE Messaging APIにあるチャネルシークレット
    const requestBody = req.rawBody; // 生データ

    // 検証
    if (!validateSignature(requestBody, channelSecret, lineSignature)) {
      res.status(400).send('lineSignature !== lineSignatureVerify');
      return;
    }

    // webhookに送られるデータ
    const webhookRequest: WebhookRequestBody = JSON.parse(
      requestBody.toString(),
    );

    // そこからイベントを取ってくる
    webhookRequest.events.forEach((event) => {
      switch (event.type) {
        // 友だち登録のイベント
        case 'follow':
          console.log('follow', event.type, event.source.userId);
          break;
      }
    });

    res.status(200).send('OK');
  }
}

上でリクエストから生データを受け取っていますが、これは main.ts での調整がちょっと必要です。

const app = await NestFactory.create(AppModule, {
  rawBody: true,
})

Service の follow イベントが発火したタイミングのログを見てみるとこんな感じのデータが返って来ています。

{
  "destination": "xxxxxxxxxx",
  "events": [
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "follow", // 友だち登録のイベント
      "mode": "active",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "U4af4980629..." // LINEのユーザID!!!!!
      },
      "webhookEventId": "01FZ74A0TDDPYRVKNK77XKC3ZR",
      "deliveryContext": {
        "isRedelivery": false
      }
    }
  ]
}

あと、署名検証を忘れてはいけませんでしたね。

Controller にアクセスする前に Middleware を設定しましょう。

Middleware(/line/line.middleware.ts)

import { middleware } from '@line/bot-sdk'
import { Injectable, NestMiddleware } from '@nestjs/common'

@Injectable()
export class LineMiddleware implements NestMiddleware {
  use(@Req() req: Request, @Res() res: Respose, next: () => void) {
    middleware({
      channelAccessToken: process.env.LINE_MESSAGE_API_CHANNEL_ACCESS_TOKEN, // チャネルアクセストークン
      channelSecret: process.env.LINE_MESSAGE_API_CHANNEL_SECRET, //  チャネルシークレット
    })(req, res, next)
  }
}

それに伴い、Module(/line/line.module.ts) も Middleware を使えるように対応しておきます。

import { MiddlewareConsumer, Module } from '@nestjs/common';
import { LineController } from './line.controller';
import { LineService } from './line.service';
import { LineMiddleware } from './line.middleware';
import { HttpModule } from '@nestjs/axios';

@Module({
  imports: [
    HttpModule,
  ],
  controllers: [LineController],
  providers: [LineService],
})
export class LineModule {
  public configure(consumer: MiddlewareConsumer) {
    consumer.apply(LineMiddleware).forRoutes(LineController);
  }
}

これで大丈夫。

5.LINE ユーザー ID から連携トークンを生成

先ほど取得することに成功した LINE のユーザー ID から連携トークンなるものを生成していきます。

この連携トークンとはなんでしょうか?

LINE ユーザーと自社サービスユーザを安全に連携するために必要なもので、ユーザーが実際にそのアカウントの所有者であるかを確認するための一時的な認証キーです。

それでは連携トークンを発行するための処理を書いていきましょう。

Service(/line/line.service.ts)に処理を追加していきます。

export class LineService {
    constructor() {}

    public async Webhook(@Req() req, @Res() res) {
        const lineSignature = req.headers['x-line-signature'];
        const channelSecret = process.env.LINE_MESSAGE_API_CHANNEL_SECRET;
        const requestBody = req.rawBody;

        if (!validateSignature(requestBody, channelSecret, lineSignature)) {
        res.status(400).send('lineSignature !== lineSignatureVerify');
        return;
        }

        const webhookRequest: WebhookRequestBody = JSON.parse(
        requestBody.toString(),
        );

        webhookRequest.events.forEach((event) => {
        switch (event.type) {å
            case 'follow':
            console.log('follow', event.type, event.source.userId);
            // LINEのユーザーIDを渡す
            this.getToken(event.source.userId);
            break;
        }
        });

        res.status(200).send('OK');
    }

    public async getToken(userId) {
        try {
            // ユーザー用の連携トークンを発行
            const request = await this.httpService.post(
                `https://api.line.me/v2/bot/user/${userId}/linkToken`, 
                {}, // 空だけどこれがないとエラーになる
                {
                    headers: {
                        Authorization: `Bearer ${process.env.LINE_MESSAGE_API_CHANNEL_ACCESS_TOKEN}`, // チャネルトークンを設定
                    },
                },
            );
            const res = await lastValueFrom(request);
            console.log('res', res);
        } catch (e) {
            return e
        }
    }
}

すると、こんな感じのデータが返ってきました!

{
  "linkToken": "NMZTNuVrPTqlr2IF8Bnymkb7rXfYv5EY"
}

あんまり関係ないかもだけど、LINE Messaginig API の管理画面で Webhook の再送をアクティブにしていると、何度もイベントが Webhook に送られてきてしまうので気をつけましょう。
まあ、こんなアホなミスはあまりないかもですが w

ちなみにこれが Webhook が再送される条件みたいですので参考までに。

LINE プラットフォームから送信された Webhook は、次の 2 つの条件を満たすときに、一定の期間内に、一定の時間を空けて再送されます。

1.Webhook の再送を有効にしている
2.Webhook に対して、ボットサーバーがステータスコード200番台を返さなかった

6.自社のログイン画面 URL を送信

そして、ここからがいよいよ連携って感じです。

1~5 まででユーザーとしてはただ友だち登録を済ませただけなので、ここで自社サービスのログイン画面 URL をメッセージとしてメッセージとして受け取って、そこにアクセスすることになります。

なので、ここではプッシュメッセージを送信する処理を書いていきます。

Service(/line/line.service.ts)

export class LineService {
    constructor() {}

    public async Webhook(@Req() req, @Res() res) {
        const lineSignature = req.headers['x-line-signature'];
        const channelSecret = process.env.LINE_MESSAGE_API_CHANNEL_SECRET;
        const requestBody = req.rawBody;

        if (!validateSignature(requestBody, channelSecret, lineSignature)) {
        res.status(400).send('lineSignature !== lineSignatureVerify');
        return;
        }

        const webhookRequest: WebhookRequestBody = JSON.parse(
        requestBody.toString(),
        );

        webhookRequest.events.forEach((event) => {
        switch (event.type) {å
            case 'follow':
            console.log('follow', event.type, event.source.userId);
            // LINEのユーザーIDを渡す
            this.getToken(event.source.userId);
            break;
        }
        });

        res.status(200).send('OK');
    }

    public async getToken(userId) {
        try {
            // ユーザー用の連携トークンを発行
            const request = await this.httpService.post(
                `https://api.line.me/v2/bot/user/${userId}/linkToken`, 
                {}, // 空だけどこれがないとエラーになる
                {
                    headers: {
                        Authorization: `Bearer ${process.env.LINE_MESSAGE_API_CHANNEL_ACCESS_TOKEN}`, // チャネルトークンを設定
                    },
                },
            );
            const res = await lastValueFrom(request);
            console.log('res', res);
            this.sendMessage(res.data.linkToken, userId);
        } catch (e) {
            return e
        }
    }

    public async sendMessage(linkToken, userId) {
        try {
            const request = await this.httpService.post(
                `https://api.line.me/v2/bot/message/push`,
                {
                to: userId, // 指定したユーザーに送る
                messages: [
                    {
                    type: 'template',
                    altText: '通知のメッセージ',
                    template: {
                        type: 'buttons', // その他、確認、カルーセル、画像カルーセルなどがある
                        thumbnailImageUrl: 'https://test.jpg', // サムネイル
                        imageAspectRatio: 'rectangle', // アスペクト比(rectangle:1.51:1,square:1:1)
                        imageSize: 'cover', // 画像の表示形式(cover:画像領域全体に画像を表示, contain:画像領域に画像全体を表示)
                        title: 'タイトル',
                        text: 'テキスト',
                        actions: [
                        {
                            type: 'uri',
                            label: 'リンクのテキスト',
                            uri: `https://frontend.jp/signin?linkToken=${linkToken}`, // 自社サービスのログイン画面URLに連携トークンをパラメーターとして設定
                        },
                        ],
                    },
                    },
                ],
                },
                {
                headers: {
                    Authorization: `Bearer ${process.env.LINE_MESSAGE_API_CHANNEL_ACCESS_TOKEN}`, // チャネルアクセストークン
                },
                },
            );
            const res = await lastValueFrom(request);
        } catch (e) {
            return e
        }
    }
}

これで無事に公式 LINE アカウントからユーザに対して、自社サービスのログイン画面 URL に連携トークンをパラメーターとして付与した URL を送ることに成功しました!!

7.ログインしてもらって自社のユーザー ID を取得

はい。それでは自社のユーザ ID を取得していきます。

6 で送信した URL をユーザーがアクセスすると自社サービスのログイン画面にアクセスします。

そのタイミングで自社サービスの方ではユーザー ID を取得できるようにしておきます。

Next.js 側でログイン後に/dashboadにリダイレクトされると想定して、そのページで Nestjs のエンドポイントを叩きます。

const linkToken = query?.["linkToken"] as string // ?linkToken=${linkToken}にリダイレクトされたのでパラメーターを取ってくる
const decoded = decodeURIComponent(linkToken || "")
(async() => {
    const response = await axios.get(`https://messaging-api.loca.lt/line/save-user/${userId}/${decoded}`)
    return response
})()

8.自社のユーザー ID を保存

そして、Nestjs の方では受け取った自社サービスのユーザー ID と連携トークンから nonce を生成していきます。

nonce とは自社サービスのユーザー ID と紐付けて保存しておいて、取得する時にも鍵となるようなランダムな文字列のことです。

その条件は以下のものです。

  • 予測が難しく一度しか使用できない文字列であること。セキュリティ上問題があるため、自社サービスのユーザー ID などの予測可能な値は使わないでください。
  • 長さは 10 文字以上 255 文字以下であること

nonce としてランダムな値を生成する際の推奨事項は以下のとおりです。

  • 128 ビット(16 バイト)以上のデータで、セキュアなランダム生成関数を使う。
  • Base64 エンコードする。

ということで、nonce と自社サービスのユーザー ID の保存をします。

まずは Nestjs の方で自社サービスのユーザー ID を取得してくるエンドポイントからですね。

Controller(/line/line.controller.ts)

import {
  Controller,
  Post,
  RawBodyRequest,
  Req,
  Res,
} from '@nestjs/common';

@Controller('line')
export class LineController {
    constructor(private service: LineService){}

    @Post('Webhook') // /line/webhookのエンドポイントを作る
    public async Webhook(
        @Req() req: RawBodyRequest<Request>,
        @Res() res: Response,
    ) {
        await this.service.Webhook(req, res);
    }

    @Get('save-user/:userId/:linkToken') //
    public async saveUser(
        @Param('userId') userId: string,
        @Param('linkToken') linkToken: string,
    ) {
        return this.service.saveUser(userId, linkToken);
    }
}

そしても Service の方も

Service(/line/line.service.ts)

export class LineService {
    constructor(@Inject('CACHE_MANAGER') private cacheManager: Cache,) {}

    public async Webhook(@Req() req, @Res() res) {
        const lineSignature = req.headers['x-line-signature'];
        const channelSecret = process.env.LINE_MESSAGE_API_CHANNEL_SECRET;
        const requestBody = req.rawBody;

        if (!validateSignature(requestBody, channelSecret, lineSignature)) {
        res.status(400).send('lineSignature !== lineSignatureVerify');
        return;
        }

        const webhookRequest: WebhookRequestBody = JSON.parse(
        requestBody.toString(),
        );

        webhookRequest.events.forEach((event) => {
        switch (event.type) {å
            case 'follow':
            console.log('follow', event.type, event.source.userId);
            this.getToken(event.source.userId);
            break;
        }
        });

        res.status(200).send('OK');
    }

    public async getToken(userId) {
        try {
            const request = await this.httpService.post(
                `https://api.line.me/v2/bot/user/${userId}/linkToken`, 
                {},
                {
                    headers: {
                        Authorization: `Bearer ${process.env.LINE_MESSAGE_API_CHANNEL_ACCESS_TOKEN}`,
                    },
                },
            );
            const res = await lastValueFrom(request);
            console.log('res', res);
            this.sendMessage(res.data.linkToken, userId);
        } catch (e) {
            return e
        }
    }

    public async sendMessage(linkToken, userId) {
        try {
            const request = await this.httpService.post(
                `https://api.line.me/v2/bot/message/push`,
                {
                    to: userId,
                    messages: [
                        {
                        type: 'template',
                        altText: '通知のメッセージ',
                        template: {
                            type: 'buttons',
                            thumbnailImageUrl: 'https://test.jpg',
                            imageAspectRatio: 'rectangle',
                            imageSize: 'cover',
                            title: 'タイトル',
                            text: 'テキスト',
                            actions: [
                            {
                                type: 'uri',
                                label: 'リンクのテキスト',
                                uri: `https://frontend.jp/signin?linkToken=${linkToken}`,
                            },
                            ],
                        },
                        },
                    ],
                },
                {
                    headers: {
                        Authorization: `Bearer ${process.env.LINE_MESSAGE_API_CHANNEL_ACCESS_TOKEN}`,
                    },
                },
            );
            const res = await lastValueFrom(request);
        } catch (e) {
            return e
        }
    }

    public async saveUser(
        userId: string,
        linkToken: string,
    ): Promise<string> {
        const nonce = randomBytes(16).toString('base64'); // nonceとしてランダムな値を生成
        this.cacheManager.set(nonce, userId); // cacheにnonceとuserIdをペアにして保存
        const accountLink = `https://access.line.me/dialog/bot/accountLink?linkToken=${linkToken}&nonce=${nonce}`;
        return accountLink;
    }
}

nonce と自社サービスのユーザー ID の保存については特に指定はなく、DB に追加したりする選択肢もあるらしいのですが、nonce は一時的な情報だし、ここでしか使わないものなので今回は Cache に保存してみることにします。

それに伴って、Module(/line/line.module.ts)にもCacheModuleimportsして設定しておきます。

@Module({
  imports: [
    HttpModule,
    CacheModule.register({}),
  ],
  controllers: [LineController],
  providers: [LineService],
})
export class LineModule {
  public configure(consumer: MiddlewareConsumer) {
    consumer.apply(LineMiddleware).forRoutes(LineController);
  }
}

それで Nestjs で作った accountLink の URL にリダイレクトさせて、ユーザーがこのエンドポイントにアクセスした時に LINE プラットフォームの方で、ユーザーが連携トークンの対象ユーザーであるかの確認を行います。

そこでアカウントが問題なく連携できた場合には Webhook でaccountLinkイベントを受け取ることができます。

Service(/line/line.service.ts)に処理を追加していく必要があります。

export class LineService {
  constructor() {}

  public async Webhook(@Req() req, @Res() res) {
    const lineSignature = req.headers['x-line-signature'];
    const channelSecret = process.env.LINE_MESSAGE_API_CHANNEL_SECRET;
    const requestBody = req.rawBody;

    if (!validateSignature(requestBody, channelSecret, lineSignature)) {
      res.status(400).send('lineSignature !== lineSignatureVerify');
      return;
    }

    const webhookRequest: WebhookRequestBody = JSON.parse(
      requestBody.toString(),
    );

    webhookRequest.events.forEach((event) => {
      switch (event.type) {
        case 'follow':
          console.log('follow', event.type, event.source.userId);
          break;
        case 'accountLink': // accountLinkイベントに対応
          console.log('accountLink', event.type, event.link.nonce);
          this.getUserId(event.link.nonce); // ここでnonceを受け取って
          break;
      }
    });

    res.status(200).send('OK');
  }
  ------
  // キャッシュに保存しておいたユーザーIDをnonceを元に取得
  public async getUserId(nonce: string) {
    const userId = await this.cacheManager.get(nonce);
    return userId;
  }
}

これで安全に自社のユーザー ID を取得してくることができました!!

10.自社の DB に LINE ユーザー ID を保存

さてさて、やっとここまできたので、あとはシンプルに LINE ユーザー ID を DB に保存するたけです。

TypeORM を使う場合で説明していきます。

まずは自社サービスのユーザー情報を扱う Entity に LINE ユーザー ID を格納するカラムを追加する必要があります。

UserEntity とします。

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number

  -----

  @Column({ nullable: true })
  lineUserId: string // LINEのユーザーIDを保存するフィールド
}

これを追加したら、マイグレーションを実行して DB の更新を行います。

そしたらまた Service(/line/line.service.ts)に処理を追加していきます。

export class LineService {
    constructor() {}

    public async Webhook(@Req() req, @Res() res) {
        const lineSignature = req.headers['x-line-signature'];
        const channelSecret = process.env.LINE_MESSAGE_API_CHANNEL_SECRET;
        const requestBody = req.rawBody;

        if (!validateSignature(requestBody, channelSecret, lineSignature)) {
        res.status(400).send('lineSignature !== lineSignatureVerify');
        return;
        }

        const webhookRequest: WebhookRequestBody = JSON.parse(
        requestBody.toString(),
        );

        webhookRequest.events.forEach((event) => {
        switch (event.type) {
            case 'follow':
            console.log('follow', event.type, event.source.userId);
            break;
            case 'accountLink':
            console.log('accountLink', event.type, event.link.nonce);
            this.getUserId(event.link.nonce, event.source.userId);
            break;
        }
        });

        res.status(200).send('OK');
    }
    ------
    public async getUserId(nonce: string) {
        const userId = await this.cacheManager.get(nonce, event.source.userId);
        const lineUserId = event.source.userId
        this.linkLineUser(userId, lineUserId)
        return userId;
    }

    // LINEユーザーIDを追加してDBに格納していく
    public async linkLineUser(userId: string, lineUserId: string) {
        const user = await this.userRepository.find({
            where: {
                userId
            }
        });
        if (user) {
        user.lineUserId = lineUserId;
        await this.userRepository.save(user);
        }
    }
}

これでようやく自社の DB の方に LINE ユーザー ID を紐づけることができました!!
ここまでがアカウント連携です。

11.自社サービスで購入イベント発生時に公式 LINE アカウントから通知

やっとここまできましたね。

ここからは単純な話で、ログイン中のユーザーの自社サービス内のユーザー ID をセッションから取得してきて、ユーザ ID と紐づいている LINE ユーザー ID を取得してきて、それをもとにして購入イベントが発生したタイミングでプッシュ通知を送ってあげるだけです。

これは特に難しいことはなさそうです。

まずは Service(/line/line.service.ts)に処理を追加していきます。

export class LineService {
    constructor() {}

    public async Webhook(@Req() req, @Res() res) {
        const lineSignature = req.headers['x-line-signature'];
        const channelSecret = process.env.LINE_MESSAGE_API_CHANNEL_SECRET;
        const requestBody = req.rawBody;

        if (!validateSignature(requestBody, channelSecret, lineSignature)) {
        res.status(400).send('lineSignature !== lineSignatureVerify');
        return;
        }

        const webhookRequest: WebhookRequestBody = JSON.parse(
        requestBody.toString(),
        );

        webhookRequest.events.forEach((event) => {
        switch (event.type) {
            case 'follow':
            console.log('follow', event.type, event.source.userId);
            break;
            case 'accountLink': // accountLinkイベントに対応
            console.log('accountLink', event.type, event.link.nonce);
            this.getUserId(event.link.nonce); // ここでnonceを受け取って
            break;
        }
        });
        res.status(200).send('OK');
    }
    ------
    // 自社サービスユーザーIDからLINEユーザーIDを取得する
    public async getLineUserIdFromUserId (userId) {
        const user = await this.userRepository.find({
            where: {
                userId
            }
        });
        if (user && user.lineUserId) {
        // LINEユーザーIDがある場合、プッシュ通知を送信
        await this.sendPushMessage(user.lineUserId);
        }
    }
    // 予約が入った旨のメッセージを送信する
    public async sendPushMessage(userId: string) {
        const lineUserId = this.getLineUserIdFromUserId(userId)
        const request = await this.httpService.post(
            `https://api.line.me/v2/bot/message/push`,
            {
                to: lineUserId,
                messages: [
                    {
                        type: 'text',
                        text: '購入ありがとうございます!', // 送信するメッセージ
                    },
                ],
            },
            {
                headers: {
                    Authorization: `Bearer ${process.env.LINE_MESSAGE_API_CHANNEL_ACCESS_TOKEN}`,
                },
            }
        );
        const res = await lastValueFrom(request);
    }
}

Controller(/line/line.controller.ts)の方も作っていきます。
これはフロントエンドからアクセスするエンドポイントになりますね。

@Controller('line')
export class LineController {
    constructor(private service: LineService){}

    @Post('Webhook')
    public async Webhook(
        @Req() req: RawBodyRequest<Request>,
        @Res() res: Response,
    ) {
        await this.service.Webhook(req, res);
    }

    @Get('save-user/:userId/:linkToken')
    public async saveUser(
        @Param('userId') userId: string,
        @Param('linkToken') linkToken: string,
    ) {
        return this.service.saveUser(userId, linkToken);
    }

    @Post('send-purchase-message/:userId')
    public async sendPushMessage(
        @Param('userId') userId: string,
    ) {
        return this.service.sendPushMessage(userId)
    }
}

こんなもんでしょうか。

あとはフロントエンドですね。

const Page = () => {
  const handlePurchase = async (userId) => {
    const response = await axios.post(
      `https://messaging-api.loca.lt/line/send-purchase-message/${userId}`
    )
    return response
  }
  return (
    <div>
      <button onClick={() => handlePurchase(userId)}>購入する</button>
    </div>
  )
}

参考

さいごに

Nestjs がメインなのでフロントエンドの書き方は雑になっちゃってすみませんでした...
でも、頑張って書いたのでなんとかイメージできたのではないかな?と思います。

今回はアカウント連携だけでしたが、ドキュメントも充実しているしユーザーにとって身近な LINE を有効的に活用することで、開発しているサービスの品質を向上できると思うので、もっとドキュメントを読んでいろいろと試してまたブログに書いていきたいと思います。

あとは LINE フレームワークの LIFF とかミニアプリとかも触ってみたいですね。