🦜

LINE Messaging APIを使ってオウム返しbotを作成する (Cloud Functions for Firebase 環境)

2023/03/25に公開

はじめに

LINE Messaging APIを使ってオウム返しをするLINE botをCloud Functions for Firebaseを使って作成してみようと思います。

まず、LINE Messaging APIとはLINEユーザーとの双方向コミュニケーションを行えるようにするための機能です。
ユーザーが送ったメッセージに対して返信をしたり、任意のタイミングでユーザーやグループにメッセージを送ることができます。
詳しくは以下のドキュメントをご覧ください。
https://developers.line.biz/ja/services/messaging-api/

完成イメージは以下です。

GitHubリポジトリ
https://github.com/yuma-ito-bd/line-messaging-api-bot

環境

主なツールのバージョンは以下。

  • Node.js: v18.15.0
  • firebase-tools (Firebase CLI): v11.24.1
  • @line/bot-sdk (LINE Messaging APIのSDK): v7.5.2
  • TypeScript: v4.9.5

LINE Messaging APIの初期設定

まず、LINE Messaging APIを利用するための設定を行います。

LINE bot用のチャネルを作成

Messaging APIのページから「今すぐ始めよう」をクリックします。

初めての場合はログインページが挟まるので、個人のLINEアカウントかビジネスアカウントでログインしてください。

新しいチャネル(今回ではLINE botのこと)用の設定を行います。
チャネルの種類は「Messaging API」を選択してください。

プロバイダー、チャネルなどの用語は以下のページで説明されています。

https://developers.line.biz/ja/docs/line-developers-console/overview/

チャネルアクセストークンの取得

Messaging APIを利用するためには、チャネルアクセストークンという認証用トークンが必要です。
チャネルアクセストークンの説明は以下に載っています。
https://developers.line.biz/ja/docs/messaging-api/channel-access-tokens/

チャネルアクセストークン v2.1 を作成する流れ

3種類のチャネルアクセストークンがありますが、v2.1というチャネルアクセストークンが推奨されています。
チャネルアクセストークンv2.1はJWTを使って任意の有効期間を指定できるトークンです。

チャネルアクセストークンの作成方法は以下です。
https://developers.line.biz/ja/docs/messaging-api/generate-json-web-token/

詳しくは上記ページに記載されているので、ここでは簡単な手順を説明します。

  1. アサーション署名キーのキーペアを生成する
    • Go, Python, ブラウザで生成する例が載っています。私はブラウザで生成しました。
  2. 「チャネル基本設定」>「アサーション署名キー」から生成した公開鍵を設定する
  3. JWTを生成する
    • Node.jsのライブラリnode-joseを利用しました。
  4. チャネルアクセストークンv2.1を発行する

アサーション署名キーのキーペアを作成

私はブラウザのコンソールを使って署名キーを作成しました。
公開鍵はLINE Developersコンソールのチャネル基本設定タブにある「公開鍵を登録する」ボタンをクリックして登録してください。
秘密鍵は後で使うのでassertion-private.key.jsonというファイルに保存しておきます。

JWTを生成する

認証用のJWTを生成をするためには以下の情報が必要です。

  • kid:アサーション署名キーの公開鍵を登録したことによって発行されたもの。「チャネル基本設定>アサーション署名キー」から取得できます
  • チャネルID:「チャネル基本設定>チャネルID」
  • アサーション署名キーの秘密鍵:前節でassertion-private.key.jsonに保存したもの

まず、環境変数として利用するべくプロジェクト配下に.envファイルを作成し、チャネルIDとkidを登録します。

.env
CHANNEL_ID=<自分のチャネルID>
KID=<自分のkid>

次にJWTを生成するためのnode-joseというパッケージと環境変数を利用するためのdotenvをインストールします。

npm i node-jose
npm i -D dotenv

そして、以下のようなmake_token.jsファイルを作成します。

make_token.js
const jose = require("node-jose");
const fs = require("fs");
const path = require("path");

require("dotenv").config();

const makeJWT = async () => {
  const privateKey = JSON.parse(
    fs.readFileSync(path.join(__dirname, "assertion-private.key.json"))
  );

  const header = {
    alg: "RS256",
    typ: "JWT",
    kid: process.env.KID, // チャネル基本設定>アサーション署名キー
  };

  const payload = {
    iss: process.env.CHANNEL_ID, // チャネルID
    sub: process.env.CHANNEL_ID, // チャネルID
    aud: "https://api.line.me/",
    exp: Math.floor(new Date().getTime() / 1000) + 60 * 25, // JWTの有効期間(UNIX時間)
    token_exp: 60 * 60 * 24 * 30, // チャネルアクセストークンの有効期間
  };

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

  return jwt;
};

const createToken = async (jwt) => {
  const accessTokenUrl = "https://api.line.me/oauth2/v2.1/token";
  const response = 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(),
  });

  return response;
};

(async () => {
  const jwt = await makeJWT();
  const accessTokenResponse = await createToken(jwt);
  console.log(await accessTokenResponse.json());
})();

このスクリプトを実行すると、新しく作成したチャネルアクセストークンがコンソールに出力されます。

$ node make_token.js
{
  access_token: 'xxxxxx.yyyyyyyyy.zzzzzz',
  token_type: 'Bearer',
  expires_in: 2592000,
  key_id: 'aaaaaaaaaaa'
}

出力されたJSONのaccess_tokenがチャネルアクセストークンです。
チャネルアクセストークンはAPIを呼び出す際に利用するので、メモしておきましょう。

(メモ)チャネルアクセストークンの発行時にエラーが発生

チャネルアクセストークンの発行を行った際に以下のようなエラーレスポンスが返ってきました。

$ node make_token.js 
{ error: 'invalid_client', error_description: 'Invalid exp' }

JWTの有効時間payload.expが正しくないようです。有効時間を30分から25分に縮めたところ、正常にチャネルアクセストークンを取得することができました。

Cloud Functions for Firebaseの利用準備

次はLINE Messaging APIからWebhookで呼ばれるバックエンドサーバーの準備をします。
今回はCloud Functions for Firebaseを利用します。(他のサービスでも利用可能ですが、Google Apps Scriptはおすすめしません。その理由はこの記事の最後をご覧ください)

Cloud Functions for Firebaseはサーバレスコンピューティングのサービスで、JavaScriptやTypeScriptの実行環境を簡単に用意してくれます。似たようなサービスではAWSのLambdaがあります。

https://firebase.google.com/docs/functions?hl=ja

スタートガイドなどを参考にプロジェクトの初期化を行います。

まず、Firebaseコンソールで[プロジェクトを追加]をクリックします。

次に、Firebase CLIをインストールします。

npm install -D firebase-tools

Firebase CLIにてGoogleアカウントにログインします。

npx firebase login

Cloud Functionsを利用するための初期化を行います。言語はTypeScriptを選択しました。

npx firebase functions

実行すると、functionsというフォルダが作成されます。

フォルダ構成は以下です。(package.jsonが2つあるので注意)

.
├── assertion-private.key.json
├── firebase.json
├── functions
│   ├── node_modules
│   ├── package-lock.json
│   ├── package.json # Cloud Functionsで実行するコードのためのpackage.json
│   ├── src
│   ├── tsconfig.dev.json
│   └── tsconfig.json
├── make_token.js
├── node_modules
├── package-lock.json
└── package.json # firebase-toolsなどローカル環境で実行するコードのためのpackage.json

以降はfunctionsフォルダ内で実装するので、functionsフォルダ内に移動してください。

cd functions

動作確認用のサンプルコードの実装を行います。
src/index.tsファイルを作成し、単純に文字列を返すようにします。

index.ts
import {https} from "firebase-functions";

export const webhook = https.onRequest((req, res) => {
  res.send("HTTP POST request sent to the webhook URL!");
});

webhookという変数をexportすることで、webhookという名前のCloud Functionsの関数が作成されるようになります。

次にデプロイをします。

npx firebase deploy --only functions

以下のようにデプロイのコマンドが実行されます。

=== Deploying to 'xxxxxxxxxxxxx'...

i  deploying functions
Running command: npm --prefix "$RESOURCE_DIR" run lint

> lint
> eslint --ext .js,.ts .

Running command: npm --prefix "$RESOURCE_DIR" run build

> build
> tsc

✔  functions: Finished running predeploy script.
i  functions: preparing codebase default for deployment
i  functions: ensuring required API cloudfunctions.googleapis.com is enabled...
i  functions: ensuring required API cloudbuild.googleapis.com is enabled...
i  artifactregistry: ensuring required API artifactregistry.googleapis.com is enabled...
✔  artifactregistry: required API artifactregistry.googleapis.com is enabled
✔  functions: required API cloudfunctions.googleapis.com is enabled
✔  functions: required API cloudbuild.googleapis.com is enabled
i  functions: preparing functions directory for uploading...
i  functions: packaged /home/xxxxxxxxxxxxxxx/functions (165.74 KB) for uploading
✔  functions: functions folder uploaded successfully
i  functions: creating Node.js 16 function webhook(us-central1)...
✔  functions[webhook(us-central1)] Successful create operation.
Function URL (webhook(us-central1)): https://xxxxxxxxxxxxxxx.cloudfunctions.net/webhook
i  functions: cleaning up build files...

✔  Deploy complete!

Firebaseのコンソール画面から以下のようにwebhookという関数が表示されていればデプロイ完了です。

関数のエンドポイント(デプロイコマンドのログの中にURLがあります)にブラウザからアクセスし、レスポンスが返ってくればOKです!

LINE Messaging APIのWebhook URLを設定する

LINE Developersコンソールに戻り、「Messaging API設定」タブからWebhook設定を行います。
Webhook URLという項目に先程作成した関数のURLを設定して「検証」ボタンを押します。「成功」が表示されればOKです。

また、「Webhookの利用」をONにしてください。

これにより、ユーザーがメッセージを登録したり、友達登録をした際にCloud Functionsでイベントを受け取れるようになりました。

オウム返し機能を実装する

さて、本命のオウム返し機能を実装します。
今回はユーザーがテキストメッセージを送ってきた場合のみ考慮し、画像やスタンプ送信はスコープ外とします。

ユーザーにメッセージを送るためにはLINE Messaging APIの利用が必要です。
「LINE Messaging API SDK for nodejs」というSDKが用意されているのでインストールしましょう。
https://line.github.io/line-bot-sdk-nodejs/

npm install @line/bot-sdk

※Node.js以外のSDKも用意されています (参照:https://developers.line.biz/ja/docs/messaging-api/line-bot-sdk/)

そして、index.tsに以下のようなコードを書きます。

index.ts
import {https, logger} from "firebase-functions";
import {defineString} from "firebase-functions/params";
import {WebhookRequestBody, Client} from "@line/bot-sdk";

// 実行時に必要なパラメータを定義
const config = {
  channelSecret: defineString("CHANNEL_SECRET"),
  channelAccessToken: defineString("CHANNEL_ACCESS_TOKEN"),
};

export const webhook = https.onRequest((req, res) => {
  res.send("HTTP POST request sent to the webhook URL!");

  // LINE Messaging API Clientの初期化
  const lineClient = new Client({
    channelSecret: config.channelSecret.value(),
    channelAccessToken: config.channelAccessToken.value(),
  });

  // ユーザーがbotに送ったメッセージをそのまま返す
  const {events} = req.body as WebhookRequestBody;
  events.forEach((event) => {
    switch (event.type) {
    case "message": {
      const {replyToken, message} = event;
      if (message.type === "text") {
        lineClient.replyMessage(replyToken, {type: "text", text: message.text});
      }

      break;
    }
    default:
      break;
    }
  });
});

(コードの解説は後ほど)

デプロイコマンドを実行します。
途中で以下のような質問が来るので、チャネルシークレットとチャネルアクセストークンを入力します。(初回のみ)

? Enter a string value for CHANNEL_SECRET: xxxx
? Enter a string value for CHANNEL_ACCESS_TOKEN: xxxxxx
  • CHANNEL_SECRET:LINE Developer Consoleの「チャネル基本設定」内にある「チャネルシークレット」
  • CHANNEL_ACCESS_TOKEN:先程取得したチャネルアクセストークン

入力が完了すると、.env.<project_ID>という.envファイルが作成されます。
ここではCloud Functions実行時のパラメータ(環境構成)を設定しています。パラメータを変更したい場合は.envファイルを更新してください。
詳しくは、環境を構成する  |  Cloud Functions for Firebase を参照してください。

さて、コードの解説をします。

// 実行時に必要なパラメータを定義
const config = {
  channelSecret: defineString("CHANNEL_SECRET"),
  channelAccessToken: defineString("CHANNEL_ACCESS_TOKEN"),
};

ここでは実行時に必要なパラメータの設定をしています。関数のデプロイ時にこれらの値が.envファイルから読み込まれます。

// LINE Messaging API Clientの初期化
const lineClient = new Client({
  channelSecret: config.channelSecret.value(),
  channelAccessToken: config.channelAccessToken.value(),
});

LINE Messaging APIにリクエストを送るためのクライアントの初期化を行います。
.value()によってパラメータの読み込みをしています。

  // ユーザーがbotに送ったメッセージをそのまま返す
  const {events} = req.body as WebhookRequestBody;

Webhookによるリクエストのbodyのパラメータは以下のドキュメントに記載されています。

https://developers.line.biz/ja/reference/messaging-api/#webhook-event-objects

例としては、

{
  "destination": "xxxxxxxxxx",
  "events": [
    {
      "type": "message",
      "message": {
        "type": "text",
        "id": "14353798921116",
        "text": "Hello, world"
      },
      "timestamp": 1625665242211,
      "source": {
        "type": "user",
        "userId": "U80696558e1aa831..."
      },
      "replyToken": "757913772c4646b784d4b7ce46d12671",
      "mode": "active",
      "webhookEventId": "01FZ74A0TDDPYRVKNK77XKC3ZR",
      "deliveryContext": {
        "isRedelivery": false
      }
    },
    {
      "type": "follow",
      "timestamp": 1625665242214,
      "source": {
        "type": "user",
        "userId": "Ufc729a925b3abef..."
      },
      "replyToken": "bb173f4d9cf64aed9d408ab4e36339ad",
      "mode": "active",
      "webhookEventId": "01FZ74ASS536FW97EX38NKCZQK",
      "deliveryContext": {
        "isRedelivery": false
      }
    },
    {
      "type": "unfollow",
      "timestamp": 1625665242215,
      "source": {
        "type": "user",
        "userId": "Ubbd4f124aee5113..."
      },
      "mode": "active",
      "webhookEventId": "01FZ74B5Y0F4TNKA5SCAVKPEDM",
      "deliveryContext": {
        "isRedelivery": false
      }
    }
  ]
}

です。複数のイベントがほぼ同時に起きた際、Webhookのリクエストはまとめて送られることがあるようです。

events.forEach((event) => {
  switch (event.type) {
  case "message": {
    const {replyToken, message} = event;
    if (message.type === "text") {
      lineClient.replyMessage(replyToken, {type: "text", text: message.text});
    }

   break;
  }
  ...
})

今回はユーザーが投稿したテキストメッセージをオウム返ししたいので、events配列の要素eventに対して

  • メッセージ送信のイベント:event.typemessage
  • メッセージがテキストメッセージ:event.message.typeが"text"

の場合にレスポンスを返すようにします。(詳しいパラメータはドキュメントにすべて載っています)

最後に、メッセージを返信します。

lineClient.replyMessage(replyToken, {type: "text", text: message.text});

返信するために必要なパラメータはドキュメントを参照してください。

応答メッセージを送る | LINE Developers

LINE botを友達登録してメッセージを送ってみる

やっとここまで来ました。
LINE Developersコンソールの「Messaging API設定>QRコード」をLINEアプリで読み込んでください。

友達登録をしてテキストメッセージを送ってみると・・・?

オウム返ししてくれました🎉

署名の検証を行う(本番環境では必須)

まだまだ終わりではありません。
本番環境にて運用する場合にはセキュリティー対策を行う必要があります。
LINEプラットフォーム以外からの不正なリクエストを防ぐために署名の検証を行います。

詳しくは、署名を検証する | LINE Developers をご確認ください。

署名を検証するには3つの方法があります。

  • SDKのmiddlewereを利用する
    • Express.jsなどのWebフレームワークを利用している場合
  • SDKのvalidateSignature関数
    • 署名の検証の前にbody-parserでリクエストのパースを行う環境(Firebase Cloud Functionsなど)や任意のタイミングで検証したい場合
  • 自作の関数で検証する

今回の場合は、Cloud Functionsを使っているので、validateSignature関数を利用することにします。

index.ts
- import {WebhookRequestBody, Client} from "@line/bot-sdk";
+ import {WebhookRequestBody, Client, SignatureValidationFailed, validateSignature} from "@line/bot-sdk";

...

export const webhook = https.onRequest((req, res) => {
  res.send("HTTP POST request sent to the webhook URL!");

+  // 署名の検証
+  const channelSecret = config.channelSecret.value();
+  const signature = req.header("x-line-signature") ?? "";
+  if (!validateSignature(req.rawBody, channelSecret, signature)) {
+    throw new SignatureValidationFailed("invalid signature");
+  }

  // LINE Messaging API Clientの初期化
  ...
});

検証

ヘッダーに無効な署名を記載を載せてPOSTリクエストを送ります

curl \
  'https://<自分の環境のURL>/webhook' \
  -H 'X-Line-Signature: invalid signature' \
  -H 'Content-Type: application/json' \
  -d '{"destination": "xxxxxxxxxx","events": [{"type": "message","message": {"type": "text","id": "14353798921116","text": "Hello, world"},"timestamp": 1625665242211,"source": {"type": "user","userId": "U80696558e1aa831..."},"replyToken": "757913772c4646b784d4b7ce46d12671","mode": "active","webhookEventId": "01FZ74A0TDDPYRVKNK77XKC3ZR","deliveryContext": {"isRedelivery": false}}]}'

Cloud Functionsのログを確認するとエラーが吐かれ、不正なリクエストを弾くことができました。

まとめ

LINE Messaging APIを使ってオウム返しbotを作成する手順を説明しました。
LINE Messaging APIのドキュメントがとても分かりやすかったので、基本的にはそちらを確認すれば大丈夫です。
LINE Messaging APIのSDKも揃っていますし、Firebase CLIも便利だったので今回の経験を生かして、次は独自のbotを作成したいです。

参考ページ

(おまけ)バックエンドサーバーの実行環境にGASを利用しようとしたが断念した

最初、サーバーの実行環境としてGAS (Google Apps Script) を利用しようとしていたのですが、以下の観点から利用しないことにしました。

GitHubで編集を提案

Discussion