Closed64

LINE の Messging API を試してみる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

アカウントの作成

メールアドレスを入力すると登録用のリンクが記載されたメールが送信される

リンクをクリックして登録画面に進む

名前とパスワードを入力する

登録が完了すると開発者登録のページが表示される

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロバイダーの作成

まずはプロバイダーを作成する必要があるらしい

プロバイダーとは何かと思ったらドキュメントにもフォームにも書いてある

プロバイダーとは、アプリを提供する組織のことです。ご自分の名前や企業名を入力してください。

プロバイダーとは、サービスを提供し、利用者の情報を取得する個人の開発者、企業、組織のことです。詳細は、ドキュメントを参照してください。

とりあえず会社名でも入れておこう

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

チャネルの作成

プロバイダー詳細ページのチャネル設定タブで Messaging API をクリックすると新規チャネル作成ページが表示される

必要事項を入力して作成ボタンをクリックする

チャネル名は公式アカウント名になり、変更するのにも時間がかかる様子なので慎重に設定する

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

[応答メッセージ]と[あいさつメッセージ]の設定

デフォルトでは 2 つとも有効になっているのでチャネル詳細ページの Messaging API 設定タブから無効にする

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

チャネルアクセストークンの発行

ここで出てきたかチャネルアクセストークン!

バージョンは v2.1 が推奨のようなのでまずはこれで発行しよう

まずはアサーション署名キーを作成する

Go、Python、JavaScript を作成する方法が紹介されている、今回は JavaScript を使用する

アサーション署名キーの公開鍵と秘密鍵を生成するコード
(async () => {
  const pair = await crypto.subtle.generateKey(
    {
      name: 'RSASSA-PKCS1-v1_5',
      modulusLength: 2048,
      publicExponent: new Uint8Array([1, 0, 1]),
      hash: 'SHA-256'
    },
    true,
    ['sign', 'verify']
  );
   
  console.log('=== private key ===');
  console.log(JSON.stringify(await crypto.subtle.exportKey('jwk', pair.privateKey), null, '  '));
   
  console.log('=== public key ===');
  console.log(JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey), null, '  ')); 
})();

秘密鍵と公開鍵はそれぞれ private.key と public.key という名前で保存する

念のため公式ドキュメントのアサーション署名キーの仕様に従っているかを確認する

https://developers.line.biz/ja/docs/messaging-api/generate-json-web-token/#assertion-signing-key-specificationn

それにしても公式ドキュメントがわかりやすくて安心感がすごい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

公開鍵の登録

LINE Developers コンソールのチャネル詳細ページでチャネル基本設定を選択する

公開鍵を登録するボタンがあるので押す

公開鍵をコピー&ペーストして登録ボタンを押す

kid が表示されるので控えておく

kid の例
00000000-0000-0000-0000-000000000000
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

JWT の生成

ここからは楽しいプログラミング

コマンド
mkdir hello-line-api
cd hello-line-api
npm init -y
npm install node-jose node-fetch@2 dotenv
npm install --save-dev ts-node @types/node @types/node-jose
touch main.ts .env private.key public.key
main.ts
import { readFileSync } from "fs";
import { JWS } from "node-jose";
import { join } from "path";
import fetch from "node-fetch";

async function main() {
  const privateKey = readFileSync(join(process.cwd(), "private.key"), "utf-8");

  // チャネルアクセストークンを取得するための JWT を発行します
  const header = {
    alg: "RS256",
    typ: "JWT",
    kid: process.env.KEY_ID!,
  };

  const payload = {
    iss: process.env.CHANNEL_ID!,
    sub: process.env.CHANNEL_ID!,
    aud: "https://api.line.me/",
    exp: Math.floor(new Date().getTime() / 1000) + 60 * 30,
    token_exp: 60 * 60 * 24 * 30,
  };

  const jwt =
    (await JWS.createSign(
      {
        format: "compact",
        fields: header,
      },
      JSON.parse(privateKey)
    )
      .update(JSON.stringify(payload))
      .final()) + "";

  // エンドポイントからチャネルアクセストークンを取得します
  const accessTokenUrl = "https://api.line.me/oauth2/v2.1/token";
  const accessTokenResponse = await fetch(accessTokenUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_assertion_type:
        "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
      client_assertion: jwt,
    }).toString(),
  });

  console.log(await accessTokenResponse.json());
}

main().catch((err) => console.error(err));
.env
KEY_ID="00000000-0000-0000-0000-000000000000"
CHANNEL_ID="0000000000"
コマンド
npx ts-node -r dotenv/config main.ts
チャネルアクセストークンの例
{
  "access_token": "eyJhbGciOiJIUz.....",
  "token_type": "Bearer",
  "expires_in": 2592000,
  "key_id": "sDTOzw5wIfxxxxPEzcmeQA"
}

無事に取得できて良かった

ここまで 1 回もつまづいていない、公式ドキュメントが素晴らし過ぎる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ここで唐突に検証したいことをまとめる

  • メッセージの受信
  • メッセージの送信
  • リッチメニュー
  • LINE アカウントと Web サイトアカウントの紐付け
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

チャネルアクセストークン取得コードのリファクタリング

Webhook で再利用するためにチャネルアクセストークン取得コードをリファクタリングしておく

コマンド
touch line-access-token.ts
line-access-token.ts
import { JWS } from "node-jose";
import fetch from "node-fetch";

// LINE Messaging API のチャンネルトークン v2.1 を取得します
// https://developers.line.biz/ja/docs/messaging-api/generate-json-web-token/
export async function getLineAccessToken(
  privateKey: string,
  keyId: string,
  channelId: string
): Promise<string> {
  // チャネルアクセストークンを取得するための JWT を発行します
  const header = {
    alg: "RS256",
    typ: "JWT",
    kid: keyId,
  };

  const payload = {
    iss: channelId,
    sub: channelId,
    aud: "https://api.line.me/",
    exp: Math.floor(new Date().getTime() / 1000) + 60 * 30,
    token_exp: 60 * 60 * 24 * 30,
  };

  const jwt =
    (await JWS.createSign(
      {
        format: "compact",
        fields: header,
      },
      JSON.parse(privateKey)
    )
      .update(JSON.stringify(payload))
      .final()) + "";

  // エンドポイントからチャネルアクセストークンを取得します
  const accessTokenUrl = "https://api.line.me/oauth2/v2.1/token";
  const accessTokenResponse = await fetch(accessTokenUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_assertion_type:
        "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
      client_assertion: jwt,
    }).toString(),
  });

  const accessTokenBody = await accessTokenResponse.json();
  return accessTokenBody.access_token;
}
main.ts
import { readFileSync } from "fs";
import { join } from "path";
import { getLineAccessToken } from "./line-access-token";

async function main() {
  const privateKey = readFileSync(join(process.cwd(), "private.key"), "utf-8");
  const keyId = process.env.KEY_ID!;
  const channelId = process.env.CHANNEL_ID!;
  const accessToken = await getLineAccessToken(privateKey, keyId, channelId);

  console.log(accessToken);
}

main().catch((err) => console.error(err));

実行コマンドは同じ

コマンド
npx ts-node -r dotenv/config main.ts
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Webhook コーディングを始める

行儀が悪いがコントローラーに直接記述していく

src/app.controller.ts
import { Body, Controller, Headers, HttpCode, Post } from '@nestjs/common';
import { AppService } from './app.service';

interface LineWebhookDto {
  destination: string;
  events: any[];
}

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @HttpCode(200)
  @Post('api/v1/line/webhook')
  onApiV1LineWebhook(
    @Headers() headers: { string: string },
    @Body() body: LineWebhookDto,
  ) {
    const lineSignature = headers['x-line-signature'];

    return {
      lineSignature,
      body,
    };
  }
}

LINE から来るであろうリクエストの curl コマンドは下記の通り

コマンド
curl -v \
  'http://localhost:3000/api/v1/line/webhook' \
  -H 'X-Line-Signature: xxx' \
  -H 'Content-Type: application/json' \
  -d '{"destination": "U00000000000000000000000000000000", "events": []}'
レスポンス
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> POST /api/v1/line/webhook HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.79.1
> Accept: */*
> X-Line-Signature: xxx
> Content-Type: application/json
> Content-Length: 66
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: application/json; charset=utf-8
< Content-Length: 94
< ETag: W/"5e-tPh1djYAleIeS8SAezHXB+FN4e0"
< Date: Thu, 16 Feb 2023 06:39:20 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< 
* Connection #0 to host localhost left intact
{"lineSignature":"xxx","body":{"destination":"U00000000000000000000000000000000","events":[]}}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

curl の代わりに node-fetch を使う

ダイジェストの作成などがあるので curl を使うよりも node-fetch を使ってテストコードを書いた方が良さそう

コマンド
npm install --save dotenv node-fetch@2
npm install --save-dev @types/node-fetch
touch test/line-webhook.e2e-spec.ts
test/line-webhook.e2e-spec.ts
import { createHmac } from 'crypto';
import fetch from 'node-fetch';

describe('LINE Webhook', () => {
  it('/ (GET)', async () => {
    const targetUrl = 'http://localhost:3000/api/v1/line/webhook';
    const requestBody = JSON.stringify({
      destination: 'U00000000000000000000000000000000',
      events: [],
    });

    const channelSecret = process.env.CHANNEL_SECRET;
    const lineSignature = createHmac('SHA256', channelSecret)
      .update(requestBody)
      .digest('base64');

    const webhookResponse = await fetch(targetUrl, {
      method: 'POST',
      headers: {
        'X-Line-Signature': lineSignature,
        'Content-Type': 'application/json',
      },
      body: requestBody,
    });

    console.log(webhookResponse.status);
    console.log(await webhookResponse.text());
  });
});
コマンド
npm run test:e2e -- line-webhook --setupFiles dotenv/config
実行結果
> hello-line-bot@0.0.1 test:e2e
> jest --config ./test/jest-e2e.json

  console.log
    {"lineSignature":"BAIoOyyfsaue4mu7nzN8NNTnJwFbi7QezFkI+LkcEZc=","body":{"destination":"U00000000000000000000000000000000","events":[]}}

      at Object.<anonymous> (line-webhook.e2e-spec.ts:26:13)

 PASS  test/line-webhook.e2e-spec.ts
  LINE Webhook
    ✓ / (GET) (27 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.676 s, estimated 2 s
Ran all test suites matching /line-webhook/i.
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

NestJS で .env を読み込む

https://docs.nestjs.com/techniques/configuration

コマンド
npm i --save @nestjs/config
src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // この行を追加します
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [ConfigModule.forRoot()], // この行を追加します
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

リクエストボディの生データにアクセスする

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    rawBody: true, // この行を追加します
  });
  await app.listen(3000);
}
bootstrap();

NestJS 公式ドキュメントの FAQ に書かれているユースケースがまさに今の状況

https://docs.nestjs.com/faq/raw-body

One of the most common use-case for having access to the raw request body is performing webhook signature verifications. Usually to perform webhook signature validations the unserialized request body is required to calculate an HMAC hash.

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

署名を検証する

src/app.controller.ts
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { createHmac } from 'crypto';
import { Request, Response } from 'express';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('api/v1/line/webhook')
  onApiV1LineWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    const lineSignature = req.headers['x-line-signature'];
    const channelSecret = process.env.CHANNEL_SECRET;
    const requestBody = req.rawBody;
    const lineSignatureVerify = createHmac('SHA256', channelSecret)
      .update(requestBody)
      .digest('base64');

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

    res.status(200).send('OK');
  }
}
コマンド
npm run test:e2e -- line-webhook --setupFiles dotenv/config
実行結果
> hello-line-bot@0.0.1 test:e2e
> jest --config ./test/jest-e2e.json

  console.log
    200

      at Object.<anonymous> (line-webhook.e2e-spec.ts:26:13)

  console.log
    OK

      at Object.<anonymous> (line-webhook.e2e-spec.ts:27:13)

 PASS  test/line-webhook.e2e-spec.ts
  LINE Webhook
    ✓ / (GET) (29 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.686 s, estimated 2 s
Ran all test suites matching /line-webhook/i.

わかりにくいがステータスコードが 200 で本文が OK であることから成功していることがわかる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

学ぶ必要があったもの

チャネルアクセストークンは学ぶ必要があった

メッセージの検証は SDK にコードがあるので学ぶ必要はなかった

SDK 公式ドキュメントが Express を対象にしているので NestJS でうまくいかなかったら Express で試してみよう

とはいえそれほど難しいことをやっている訳ではないのでまずは SDK に頼らずに試してみても良いかも

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

validateSignature を使う

SDK の validateSignature の方が crypto.timingSafeEqual が使われていてよりセキュア

src/app.controller.ts
import { validateSignature } from '@line/bot-sdk';
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('api/v1/line/webhook')
  onApiV1LineWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    const lineSignature = req.headers['x-line-signature'] as string;
    const channelSecret = process.env.CHANNEL_SECRET;
    const requestBody = req.rawBody;

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

    res.status(200).send('OK');
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

とりあえず今のままでも受信はできそう

src/app.controller.ts
import { validateSignature, WebhookRequestBody } from '@line/bot-sdk';
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('api/v1/line/webhook')
  onApiV1LineWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    const lineSignature = req.headers['x-line-signature'] as string;
    const channelSecret = process.env.CHANNEL_SECRET;
    const requestBody = req.rawBody;

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

    const body = JSON.parse(requestBody.toString()) as WebhookRequestBody;
    console.log(body);

    res.status(200).send('OK');
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

GCP Cloud Run にデプロイする

コマンド
touch .gcloudignore
.gcloudignore
/.git/
/dist/
/node_modules/
/test/
/.env

.DS_Store
コマンド
gcloud meta list-files-for-upload
実行結果
nest-cli.json
README.md
.gitignore
package-lock.json
package.json
tsconfig.build.json
.prettierrc
.eslintrc.js
tsconfig.json
.gcloudignore
src/main.ts
src/app.service.ts
src/app.module.ts
src/app.controller.spec.ts
src/app.controller.ts

package.json の start スクリプトも変更する、start:prod と同じにする

package.json
{
  "scripts": {
    "start": "node dist/main"
  }
}
コマンド
gcloud run deploy hello-line-bot \
  --source . \
  --platform managed \
  --region asia-northeast1 \
  --allow-unauthenticated
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

しまった PORT を忘れていた

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    rawBody: true,
  });
  await app.listen(process.env.PORT); // この行を変更します
}
bootstrap();

.env に PORT を追加するのを忘れないようにする

コマンド
gcloud run deploy hello-line-bot \
  --source . \
  --platform managed \
  --region asia-northeast1 \
  --allow-unauthenticated

また失敗、Cloud Build のログを見ると build スクリプトが実行されていない様子だ

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ローカルでビルドを再現する方法

コマンド
brew install buildpacks/tap/pack
pack build --builder=gcr.io/buildpacks/builder hello-line-bot

ローカルでやっても build スクリプトは実行されていない様子

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

やっと見つけた

どうやら build ではなくて gcp-build ビルドのようだ

https://cloud.google.com/docs/buildpacks/nodejs#executing_custom_build_steps_during_deployment

package.json
{
  "scripts": {
    "gcp-build": "nest build"
  }
}

なぜ変えたと言いたくなる笑

ローカルで pack コマンドを実行した所、成功した様子

Node.js のバージョンが 19 と高いのが気になるがとりあえずそのままにしておく

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Webhook の設定

LINE Developers コンソールで Webhook の設定を行う

検証ボタンが表示されるので押してみると失敗してしまった

テストコードを使って確認してみる、少しだけ変更する

line-webhook.e2e-spec.ts
import { createHmac } from 'crypto';
import fetch from 'node-fetch';

describe('LINE Webhook', () => {
  it('/ (GET)', async () => {
    // 次の行を変更しました
    const targetUrl = process.env.WEBHOOK_URL || 'http://localhost:3000/api/v1/line/webhook';
    const requestBody = JSON.stringify({
      destination: 'U00000000000000000000000000000000',
      events: [],
    });

    const channelSecret = process.env.CHANNEL_SECRET;
    const lineSignature = createHmac('SHA256', channelSecret)
      .update(requestBody)
      .digest('base64');

    const webhookResponse = await fetch(targetUrl, {
      method: 'POST',
      headers: {
        'X-Line-Signature': lineSignature,
        'Content-Type': 'application/json',
      },
      body: requestBody,
    });

    console.log(webhookResponse.status);
    console.log(await webhookResponse.text());
  });
});
コマンド
WEBHOOK_URL="https://hello-line-bot-xdblopffpq-an.a.run.app/api/v1/line/webhook" \
npm run test:e2e -- line-webhook --setupFiles dotenv/config
実行結果
> hello-line-bot@0.0.1 test:e2e
> jest --config ./test/jest-e2e.json

  console.log
    200

      at Object.<anonymous> (line-webhook.e2e-spec.ts:26:13)

  console.log
    OK

      at Object.<anonymous> (line-webhook.e2e-spec.ts:27:13)

 PASS  test/line-webhook.e2e-spec.ts
  LINE Webhook
    ✓ / (GET) (206 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.871 s, estimated 3 s
Ran all test suites matching /line-webhook/i.

テストコードの方はうまくいく

試しにもう一回、検証ボタンを押したら今度はうまく行った、なんなんだよ笑

Webhook URL の下に Webhook の利用というスイッチがあるので忘れずに ON にしておく

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

遂に実機での検証

Messaging API 設定タブに表示されているボットのベーシック ID や QR コードを使って LINE アプリでお友達登録する

「こんにちは」など適当なメッセージを送ってみる

Cloud Run のログでメッセージ表示されているかどうか確認する

確認したら follow イベントと message イベントがしっかり表示されていた、嬉しい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

引き続きメッセージ送信

色々なメッセージの種類があるけど主に使いそうなのは次の 2 つ

  • 応答メッセージ:ユーザーからのメッセージ送信に対して受動的にメッセージを送信する
  • プッシュメッセージ:こちらから能動的にメッセージを送信する

応答メッセージでは replyToken を指定すれば良いので簡単そう

一方、プッシュメッセージではユーザー ID を指定する必要があるので何らかの手段で事前に知っておく必要がある

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

応答メッセージ

下記コマンドのようなリクエストを送信すれば良いようだ

コマンド
curl -v -X POST https://api.line.me/v2/bot/message/reply \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {channel access token}' \
-d '{
    "replyToken":"nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
    "messages":[
        {
            "type":"text",
            "text":"Hello, user"
        },
        {
            "type":"text",
            "text":"May I help you?"
        }
    ]
}'
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

チャネルアクセストークンを取得する準備

コマンド
npm install --save node-jose
npm install --save-dev @types/node-jose
mkdir src/lib
touch src/lib/line-access-token.ts
src/lib/line-access-token.ts
import { JWS } from 'node-jose';
import fetch from 'node-fetch';

// LINE Messaging API のチャンネルトークン v2.1 を取得します
// https://developers.line.biz/ja/docs/messaging-api/generate-json-web-token/
export async function getLineAccessToken(
  privateKey: string,
  keyId: string,
  channelId: string,
): Promise<string> {
  // チャネルアクセストークンを取得するための JWT を発行します
  const header = {
    alg: 'RS256',
    typ: 'JWT',
    kid: keyId,
  };

  const payload = {
    iss: channelId,
    sub: channelId,
    aud: 'https://api.line.me/',
    exp: Math.floor(new Date().getTime() / 1000) + 60 * 30,
    token_exp: 60 * 60 * 24 * 30,
  };

  const jwt =
    (await JWS.createSign(
      {
        format: 'compact',
        fields: header,
      },
      JSON.parse(privateKey),
    )
      .update(JSON.stringify(payload))
      .final()) + '';

  // エンドポイントからチャネルアクセストークンを取得します
  const accessTokenUrl = 'https://api.line.me/oauth2/v2.1/token';
  const accessTokenResponse = await fetch(accessTokenUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_assertion_type:
        'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
      client_assertion: jwt,
    }).toString(),
  });

  const accessTokenBody = await accessTokenResponse.json();
  return accessTokenBody.access_token;
}

送信する度に毎回チャネルアクセストークンを取得しようと思っているけど、たくさん発行したらエラーにならないか少し心配

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

秘密鍵、Key ID、チャネル ID を環境変数に設定する

.env
PORT='3000'
CHANNEL_SECRET='00000000000000000000000000000000'
PRIVATE_KEY='{"alg":"RS256", ... }'
KEY_ID='00000000-0000-0000-0000-000000000000'
CHANNEL_ID='0000000000'

秘密鍵は頑張って 1 行にして設定する

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

エコーボットを実装する

受信したメッセージをそのまま送信するボットを作ってみる

src/app.controller.ts
import { validateSignature, WebhookRequestBody } from '@line/bot-sdk';
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import fetch from 'node-fetch';
import { AppService } from './app.service';
import { getLineAccessToken } from './lib/line-access-token';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('api/v1/line/webhook')
  async onApiV1LineWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    const lineSignature = req.headers['x-line-signature'] as string;
    const channelSecret = process.env.CHANNEL_SECRET;
    const requestBody = req.rawBody;

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

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

    for (const event of webhookRequest.events) {
      if (event.type === 'message' && event.message.type === 'text') {
        const channelAccessToken = await getLineAccessToken(
          process.env.PRIVATE_KEY,
          process.env.KEY_ID,
          process.env.CHANNEL_ID,
        );

        const replyUrl = 'https://api.line.me/v2/bot/message/reply';
        const replyResponse = await fetch(replyUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json; charset=UTF-8',
            Authorization: `Bearer ${channelAccessToken}`,
          },
          body: JSON.stringify({
            replyToken: event.replyToken,
            messages: [
              {
                type: 'text',
                text: event.message.text,
              },
            ],
          }),
        });

        if (replyResponse.status !== 200) {
          console.warn(await replyResponse.text());
        }
      }
    }

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

テストコードも少し変更する

test/line-webhook.e2e-spec.ts
import { createHmac } from 'crypto';
import fetch from 'node-fetch';

describe('LINE Webhook', () => {
  it('/ (GET)', async () => {
    const targetUrl =
      process.env.WEBHOOK_URL || 'http://localhost:3000/api/v1/line/webhook';
    const requestBody = JSON.stringify({
      destination: 'U00000000000000000000000000000000',
      events: [
        {
          type: 'message',
          replyToken: 'replyToken',
          message: {
            type: 'text',
            text: 'Hello LINE',
          },
        },
      ],
    });

    const channelSecret = process.env.CHANNEL_SECRET;
    const lineSignature = createHmac('SHA256', channelSecret)
      .update(requestBody)
      .digest('base64');

    const webhookResponse = await fetch(targetUrl, {
      method: 'POST',
      headers: {
        'X-Line-Signature': lineSignature,
        'Content-Type': 'application/json',
      },
      body: requestBody,
    });

    console.log(webhookResponse.status);
    console.log(await webhookResponse.text());
  });
});
コマンド
npm run test:e2e -- line-webhook --setupFiles dotenv/config
実行結果(NestJS 側)
{"message":"Invalid reply token"}

reply token が不正なのでこの実行結果は期待した通り

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

この状態でデプロイする

毎回コマンドを探すのも大変なので package.json の deploy スクリプトとして登録する

package.json
{
  "scripts": {
    "deploy": "gcloud run deploy hello-line-bot --source . --platform managed --region asia-northeast1 --allow-unauthenticated"
  }
}

これで下記を実行するだけで済む

コマンド
npm run deploy
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

動作確認

「こんにちは」とメッセージを送信してみたが返ってこない、悲しい

そうだ、環境変数の登録を忘れていた

  • PRIVATE_KEY
  • KEY_ID
  • CHANNEL_ID

今度は「こんにちは」というメッセージが返ってきた、嬉しい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

今度はプッシュメッセージを試してみる

プッシュメッセージを送るには宛先ユーザー ID が必要

LINE Developers コンソールや公式アカウントマネージャーで調べれば出てくるかと思ったけどそうでも無さそう

仕方がないので Webhook の方に手を加えてユーザー ID をログに残すようにしてみる

src/app.controller.ts
import { validateSignature, WebhookRequestBody } from '@line/bot-sdk';
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import fetch from 'node-fetch';
import { AppService } from './app.service';
import { getLineAccessToken } from './lib/line-access-token';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('api/v1/line/webhook')
  async onApiV1LineWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    const lineSignature = req.headers['x-line-signature'] as string;
    const channelSecret = process.env.CHANNEL_SECRET;
    const requestBody = req.rawBody;

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

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

    // for (const event of webhookRequest.events) {
    //   if (event.type === 'message' && event.message.type === 'text') {
    //     const channelAccessToken = await getLineAccessToken(
    //       process.env.PRIVATE_KEY,
    //       process.env.KEY_ID,
    //       process.env.CHANNEL_ID,
    //     );

    //     const replyUrl = 'https://api.line.me/v2/bot/message/reply';
    //     const replyResponse = await fetch(replyUrl, {
    //       method: 'POST',
    //       headers: {
    //         'Content-Type': 'application/json; charset=UTF-8',
    //         Authorization: `Bearer ${channelAccessToken}`,
    //       },
    //       body: JSON.stringify({
    //         replyToken: event.replyToken,
    //         messages: [
    //           {
    //             type: 'text',
    //             text: event.message.text,
    //           },
    //         ],
    //       }),
    //     });

    //     if (replyResponse.status !== 200) {
    //       console.warn(await replyResponse.text());
    //     }
    //   }
    // }

    console.log(JSON.stringify(webhookRequest));

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

念のためテスト

コマンド
npm run test:e2e -- line-webhook --setupFiles dotenv/config
実行結果
{"destination":"U00000000000000000000000000000000","events":[{"type":"message","replyToken":"replyToken","message":{"type":"text","text":"Hello LINE"}}]}

この状態でデプロイ

コマンド
npm run deploy
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

デプロイしている間にテストコードを作成する

コマンド
touch test/line-push-message.e2e-spec.ts
line-push-message.e2e-spec.ts
import fetch from 'node-fetch';
import { getLineAccessToken } from '../src/lib/line-access-token';

describe('LINE Webhook', () => {
  it('/ (GET)', async () => {
    const channelAccessToken = await getLineAccessToken(
      process.env.PRIVATE_KEY,
      process.env.KEY_ID,
      process.env.CHANNEL_ID,
    );

    const pushMessageUrl = 'https://api.line.me/v2/bot/message/push';
    const pushMessageResponse = await fetch(pushMessageUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json; charset=UTF-8',
        Authorization: `Bearer ${channelAccessToken}`,
      },
      body: JSON.stringify({
        to: process.env.TO,
        messages: [
          {
            type: 'text',
            text: 'プッシュメッセージです',
          },
        ],
      }),
    });

    if (pushMessageResponse.status !== 200) {
      console.warn(await pushMessageResponse.text());
    }
  });
});
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ユーザー ID を調べる

デプロイが終わったら例によって「こんにちは」などのメッセージを送信する

Cloud Run のログに Webhook リクエストのログが記録されるので確認する

パスは events[0].source.userId

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プッシュメッセージの送信

下記のコマンドを実行する、TO は送信先のユーザー ID

コマンド
TO=U00000000000000000000000000000000 \
  npm run test:e2e -- line-push-message --setupFiles dotenv/config

LINE アプリでプッシュメッセージが送信されているかどうかを確認する

プッシュメッセージですと表示されれば成功

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

無事にメッセージの送信と受信ができた

特につまづくことなく(gcp-build くらい)終わることができた

LINE のドキュメントの素晴らしさに感銘を受けた

引き続きアプリ上での LINE アカウントの紐付けについて調べていく

多分 LINE ログインなどを使うのだろう

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

迷う必要もなかった

Messaging API を使うのが王道

自前のやり方でも実装できるかも知れないが脆弱性を作ってしまうかも知れないのでやめておく

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

チャットと Webhook の同時利用

LINE Official Account Manager から設定できる

こうすることで手動対応もできる

プッシュメッセージの上限を使い切ってしまった時などに便利

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ユーザーアカウントの連携

まずはリンクトークンを取得する

ソースコードに下記の変更を加える

src/app.controller.ts
import { validateSignature, WebhookRequestBody } from '@line/bot-sdk';
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import fetch from 'node-fetch';
import { AppService } from './app.service';
import { getLineAccessToken } from './lib/line-access-token';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('api/v1/line/webhook')
  async onApiV1LineWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    // 署名を検証して LINE からのアクセスであることを検証します
    const lineSignature = req.headers['x-line-signature'] as string;
    const channelSecret = process.env.CHANNEL_SECRET;
    const requestBody = req.rawBody;

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

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

    const channelAccessToken = await getLineAccessToken(
      process.env.PRIVATE_KEY,
      process.env.KEY_ID,
      process.env.CHANNEL_ID,
    );

    for (const event of webhookRequest.events) {
      if (event.type === 'message' && event.message.type === 'text') {
        // 連携トークンを取得します
        const userId = event.source.userId;

        if (!userId) {
          continue;
        }

        const linkTokenUrl = `https://api.line.me/v2/bot/user/${userId}/linkToken`;
        const linkTokenResponse = await fetch(linkTokenUrl, {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${channelAccessToken}`,
          },
        });

        if (linkTokenResponse.status !== 200) {
          console.warn(await linkTokenResponse.text());
          continue;
        }

        const linkToken: string = (await linkTokenResponse.json()).linkToken;

        // ユーザーを連携 URL にリダイレクトします
        const replyUrl = 'https://api.line.me/v2/bot/message/reply';
        const replyResponse = await fetch(replyUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json; charset=UTF-8',
            Authorization: `Bearer ${channelAccessToken}`,
          },
          body: JSON.stringify({
            replyToken: event.replyToken,
            messages: [
              {
                type: 'template',
                altText: 'Account Link',
                template: {
                  type: 'buttons',
                  text: 'Account Link',
                  actions: [
                    {
                      type: 'uri',
                      label: 'Account Link',
                      uri:
                        process.env.BASE_URL +
                        '/link?' +
                        new URLSearchParams({
                          linkToken: linkToken,
                        }).toString(),
                    },
                  ],
                },
              },
            ],
          }),
        });

        if (replyResponse.status !== 200) {
          console.warn(await replyResponse.text());
        }
      }
    }

    // 送信者のユーザー ID を確認するためコンソール出力しています
    console.log(JSON.stringify(webhookRequest));

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

.env に BASE_URL を追加する

.env
BASE_URL='http://localhost:3000'

テストコードにも変更を加える

test/line-webhook.e2e-spec.ts
import { createHmac } from 'crypto';
import fetch from 'node-fetch';

describe('LINE Webhook', () => {
  it('/ (GET)', async () => {
    const targetUrl =
      process.env.WEBHOOK_URL || 'http://localhost:3000/api/v1/line/webhook';
    const requestBody = JSON.stringify({
      destination: 'U00000000000000000000000000000000',
      events: [
        {
          type: 'message',
          replyToken: 'replyToken',
          source: {
            userId: process.env.USER_ID || 'U11111111111111111111111111111111',
          },
          message: {
            type: 'text',
            text: 'Hello LINE',
          },
        },
      ],
    });

    const channelSecret = process.env.CHANNEL_SECRET;
    const lineSignature = createHmac('SHA256', channelSecret)
      .update(requestBody)
      .digest('base64');

    const webhookResponse = await fetch(targetUrl, {
      method: 'POST',
      headers: {
        'X-Line-Signature': lineSignature,
        'Content-Type': 'application/json',
      },
      body: requestBody,
    });

    console.log(webhookResponse.status);
    console.log(await webhookResponse.text());
  });
});

とりあえずテスト

コマンド
npm run test:e2e -- line-webhook --setupFiles dotenv/config

デプロイ

コマンド
npm run deploy

LINE でメッセージを送ってみるが URL が返ってこない...

ログを確認すると下記のメッセージを発見

メッセージ
A message (messages[0]) in the request body is invalid

ボットから送信するメッセージのフォーマットを確認してみる

        const replyResponse = await fetch(replyUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json; charset=UTF-8',
            Authorization: `Bearer ${channelAccessToken}`,
          },
          body: JSON.stringify({
            replyToken: event.replyToken,
            messages: [
              {
                type: 'template',
                altText: 'Account Link',
                template: {
                  type: 'buttons',
                  text: 'Account Link',
                  actions: [
                    {
                      type: 'uri',
                      label: 'Account Link',
                      uri:
                        process.env.BASE_URL +
                        '/link?' +
                        new URLSearchParams({
                          linkToken: linkToken,
                        }).toString(),
                    },
                  ],
                },
              },
            ],
          }),
        });

フォーマットは大丈夫そうだ

そうだった、BASE_URL 環境変数を Cloud Run で追加するのを忘れていた

できた様子だが PC 版の LINE だと確認できないのでスマホで確認する必要がある

テンプレートメッセージも良し悪しですね

スマホで確認したら問題なくボタンが表示されていた、良かった

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

LINEプラットフォームにリダイレクトする

ソースコードの変更、ちなみにリプライメッセージでも link → api/v1/line/link に変更しています

src/app.controller.ts
  // nonce を生成してユーザーを LINE プラットフォームにリダイレクトします
  @Get('api/v1/line/link')
  async onLink(@Req() req: RawBodyRequest<Request>, @Res() res: Response) {
    const linkToken = req.query.linkToken as string;
    const nonce = 'nonce-nonce-nonce';
    const redirectUrl =
      'https://access.line.me/dialog/bot/accountLink?' +
      new URLSearchParams({
        linkToken: linkToken,
        nonce: nonce,
      });

    res.redirect(redirectUrl);
  }

テストコードの作成

コマンド
touch test/line-link.e2e-spec.ts
test/line-link.e2e-spec.ts
import { strictEqual } from 'assert';
import fetch from 'node-fetch';

describe('LINE Link', () => {
  it('/ (GET)', async () => {
    const targetUrl =
      process.env.BASE_URL + '/api/v1/line/link?linkToken=linkToken';

    const linkResponse = await fetch(targetUrl, {
      redirect: 'manual',
    });

    strictEqual(linkResponse.status, 302);
    strictEqual(
      linkResponse.headers.get('location'),
      'https://access.line.me/dialog/bot/accountLink?linkToken=linkToken&nonce=nonce-nonce-nonce',
    );
  });
});

テストコードの実行

コマンド
npm run test:e2e -- line-link --setupFiles dotenv/config

デプロイ

コマンド
npm run deploy
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

デプロイしている間は暇なのでお勉強

https://developers.line.biz/ja/docs/messaging-api/linking-accounts/#unlinking

アカウント連携機能を使う場合は、以下の2点を遵守してください。

  • ユーザーに連携解除機能を必ず提供すること
  • ユーザーがアカウントを連携するときに、連携解除機能があることを通知すること
    たとえば、Messaging APIを使えば、表示するリッチメニューをユーザーごとに変更できます。アカウントを連携していないユーザーにはアカウントを連携するメニューを表示し、アカウントを連携済みのユーザーには連携を解除するメニューを表示すれば、ユーザーにとって使いやすい形でアカウント連携機能を提供できるでしょう。

リッチメニューをユーザーごとに変更できることが書かれている、すごいな、どうやればできるんだろう

https://developers.line.biz/ja/docs/messaging-api/using-rich-menus/#creating-a-rich-menu-using-the-messaging-api

それらしいドキュメントを発見した

連携していない場合は連携の案内メッセージ、連携している場合は連携解除の案内メッセージをリプライすることでも対応できそう

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

お勉強している間にデプロイが終わった

スマホの LINE アプリでもう一度、連携 URL を発行してみる(URL を変更したので)

Account Link ボタンを押すと「LINE とロレムイプサム開発アカウントを連携しました」とのメッセージが表示された、成功して嬉しい

念のため Cloud Run の方でアカウント連携イベントを受信していることをログから確認する

成功していれば events[0].link.result === 'ok'events[0].link.nonce に nonce が格納されるので、これを元にボットの方で自社アプリのユーザーと LINE のユーザー ID を紐付ける

LINE のユーザー ID は events[0].source.userId に格納されている

nonce を発行時にユーザーと紐付けておく必要がある

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

おわりに

これにて必要最小限の検証が完了した

LINE プラットフォームはドキュメントもそれ自体もクオリティが素晴らしいと感じた

一旦クローズするが、また調べたいことがあればオープンして続けようと思う

このスクラップは2023/02/21にクローズされました