LINE の bot-sdk がバージョンアップされたのでインターフェースを見直した

LINE の bot-sdk ライブラリが v8 になったことで、自身の LINE 環境にて気になった警告が発生したため、対処いたしました。
2023.11.27

こんにちは、高崎@アノテーション です。

はじめに

最近、自身の LINE のボット環境にライブラリアップデートを施すとこんな警告が出てきました。

非推奨というのは、筆者自身の偏見もありますが、

近々これは消す方向になるので
その時にエラーが出ても知りませんよ?

と、ライブラリ提供側が警告しているという認識です。

LINE の sdk-bot ライブラリが刷新されたことで、それまでの使い方をしたままライブラリアップデート1 を更に行うと将来使えなくなるかも、と言うことで、自身の環境を新しくしてみました。

ソースについては例によって下記の記事に記載されているソースを使用します。

また、LINE ボットを準備する LINE Messaging API の作成や設定については下記を参考にしてください。

環境構築

ソースの一覧は下記のような構成です。
※主要なファイルのみ記載しています。

\
┣ bin
┃  ┗ line_bot_new_if.ts  ← こちらは今回は変更しません
┣ lib
┃  ┗ line_bot_new_if-stack.ts    ← cdk スタック定義ソース
┣ src
┃ ┗ lambda
┃       ┗ index.ts            ← Lambda 定義ソース
┗ 他

改めて構築方法

以下のコマンドで骨格を作成します。

$ mkdir LineBotNewIF
$ cd LineBotNewIF
$ cdk init app --language typescript
cdk のソースは下記です。(先述のリンク先に記載がありますのでたたみます)

lib/line_bot_new_if-stack.ts

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';

export class LineBotTestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Lambda 関数の作成
    const lambdaParrotingBot = new lambda.Function(this, 'LineParrotingBot', {
        runtime: lambda.Runtime.NODEJS_18_X,
        handler: 'index.handler',
        code: lambda.Code.fromAsset('src/lambda'),
        environment: {
            ACCESS_TOKEN: "You must change here to your LINE Developer's access token code.",
            CHANNEL_SECRET: "You must change here to your LINE Developer's channel secret code.",
        }
    });
    // API Gateway の作成
    const api = new apigateway.RestApi(this, 'LineParrotingApi', {
        restApiName: 'LineParrotingApi'
    });
    // proxy ありで API Gateway に渡すインテグレーションを作成
    const lambdaInteg = new apigateway.LambdaIntegration(
        lambdaParrotingBot, { proxy: true });
    // API Gateway の POST イベントと Lambda との紐付け
    api.root.addMethod('POST', lambdaInteg);
  }
}

Lambda ソースの前に実行するコマンドは下記です。

$ mkdir -p src/lambda
$ cd src/lambda
$ touch index.ts
$ npm init
※聞かれた項目はそのまま Enter を押して package.json を生成。
$ npm install @types/aws-lambda
$ npm install @line/bot-sdk
以下、Lamnda のソースですが、ハイライトした行が警告箇所です。(先述のリンク先に記載がありますのでたたみます)

src/lambda/index.ts

import * as Line from '@line/bot-sdk';
import * as Types from '@line/bot-sdk/lib/types';
import * as Lambda from 'aws-lambda';

const client = new Line.Client({
  channelAccessToken: process.env.ACCESS_TOKEN!,
  channelSecret: process.env.CHANNEL_SECRET
});
const resultError: Lambda.APIGatewayProxyResult = {
  statusCode: 500,
  body: "Error"
}
const resultOK: Lambda.APIGatewayProxyResult = {
  statusCode: 200,
  body: "OK"
}

export const handler = async (eventLambda: Lambda.APIGatewayProxyEvent, contextLambda: Lambda.Context): Promise => {
  console.log(JSON.stringify(eventLambda));
  // ヘッダ編集(大文字小文字関係なく「X-Line-Signature」へ置き直す)
  const structHeader = JSON.parse(JSON.stringify(eventLambda.headers).replace(/X-Line-Signature/gi, "X-Line-Signature"));
  const stringSignature = structHeader["X-Line-Signature"];
  // Line の署名認証
  if(!Line.validateSignature(eventLambda.body!, client.config.channelSecret!, stringSignature!)){
    // 署名検証がエラーの場合はログを出してエラー終了
    console.log("署名認証エラー", stringSignature!);
    return resultError;
  }
  // 文面の解析
  const bodyRequest: Line.WebhookRequestBody = JSON.parse(eventLambda.body!);
  if ( typeof bodyRequest.events[0] === "undefined" ) {
    // LINE Developer による Webhook の検証は events が空配列の body で来るのでその場合は 200 を返す
    console.log('Webhook inspection');
    return resultOK;
  }
  if (bodyRequest.events[0].type !== 'message' || bodyRequest.events[0].message.type !== 'text'){
    // text ではない場合は終了する
    console.log("本文がテキストではない", bodyRequest);
    return resultError;
  } else {
    // 文面をそのままオウム返しする
    const messageReply: Types.Message = {
      'type': 'text',
      'text': bodyRequest.events[0].message.text
    }
    await client.replyMessage(bodyRequest.events[0].replyToken, messageReply);
    // OK 返信をセット
    return resultOK;
  }
}

バージョンアップの概要

※2023/11/27 現在の話で、今後バージョンアップにより変更があるかもしれません。

サンプルソースやドキュメントを読んでいると、今までのバージョンでは Messaging API のクライアントはClientと一つのクラスで定義していましたが、どうやら、

  • MessagingAPIClient
  • MessagingAPIBlobClient

に分けて定義するようになり、後者の Blob(Binary Large OBject の略と思いますが)クラスを定義しているソース を見ると、

  • ビデオ等のコンテンツをダウンロードする
  • リッチメニューに画像を添付する

といった API が実装されていましたので、サイズが大きいオブジェクトを扱う API を Blob と称したクラスに移動したようです。

修正について

1. Client クラスの見直し

bot-sdk が用意する GitHub のサンプルソース を見ると下記になっていました。

line-bot-sdk-nodejs/blob/master/examples/echo-bot-ts/index.ts

// Import all dependencies, mostly using destructuring for better view.
import {
  ClientConfig,
  MessageAPIResponseBase,
  messagingApi,
  middleware,
  MiddlewareConfig,
  webhook,
} from '@line/bot-sdk';
import express, {Application, Request, Response} from 'express';

// Setup all LINE client and Express configurations.
const clientConfig: ClientConfig = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN || '',
};

const middlewareConfig: MiddlewareConfig = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.CHANNEL_SECRET || '',
};

const PORT = process.env.PORT || 3000;

// Create a new LINE SDK client.
const client = new messagingApi.MessagingApiClient(clientConfig);

// Create a new Express application.
const app: Application = express();

// Function handler to receive the text.
const textEventHandler = async (event: webhook.Event): Promise<MessageAPIResponseBase | undefined> => {
  // Process all variables here.
  if (event.type !== 'message' || !event.message || event.message.type !== 'text') {
    return;
  }

  // Process all message related variables here.
  // Create a new message.
  // Reply to the user.
  await client.replyMessage({
    replyToken: event.replyToken as string,
    messages: [{
      type: 'text',
      text: event.message.text,
    }],
  });
};

// Register the LINE middleware.
// As an alternative, you could also pass the middleware in the route handler, which is what is used here.
// app.use(middleware(middlewareConfig));

// Route handler to receive webhook events.
// This route is used to receive connection tests.
app.get(
  '/',
  async (_: Request, res: Response): Promise<Response> => {
    return res.status(200).json({
      status: 'success',
      message: 'Connected successfully!',
    });
  }
);

// This route is used for the Webhook.
app.post(
  '/callback',
  middleware(middlewareConfig),
  async (req: Request, res: Response): Promise<Response> => {
    const callbackRequest: webhook.CallbackRequest = req.body;
    const events: webhook.Event[] = callbackRequest.events!;

    // Process all the received events asynchronously.
    const results = await Promise.all(
      events.map(async (event: webhook.Event) => {
        try {
          await textEventHandler(event);
        } catch (err: unknown) {
          if (err instanceof Error) {
            console.error(err);
          }

          // Return an error message.
          return res.status(500).json({
            status: 'error',
          });
        }
      })
    );

    // Return a successfull message.
    return res.status(200).json({
      status: 'success',
      results,
    });
  }
);

// Create a server and listen to it.
app.listen(PORT, () => {
  console.log(`Application is live and listening on port ${PORT}`);
});

着目するところにハイライトしましたが、ざっと記載すると下記になります。

  • messagingApi.MessagingApiClientインスタンスを生成するように変更
    • チャンネルアクセストークンがパラメータ
  • リプライは生成したインスタンスからreplyMessageをコールする
    • メッセージ設定が配列固定に変わっている(以前は配列/単数の両方を包含していた)

2. トークンについて

LINE トークンの2つの定義を環境変数に置かず、Systems Manager のパラメータストアのシークレット文字に置くようにしました。

パラメータの設定方法は以下の通りです。

2-1. Systems Manager のパラメータストアを選択

2-2. パラメータの作成を選択

2-3. 名前を入れ、安全な文字列を選択

2-4. 値に定義したい設定(シークレット系のトークン文字)を入れて、パラメータを作成を選択

これをそれぞれの2つのトークンに対して行ってください。

2-5. ソースから使用するには

スタックを定義する際に Lambda が Systems Manager を使えるように権限付与する必要がありますが、下記のようになります。

lib/line_bot_new_if-stack.ts

import { StringParameter } from 'aws-cdk-lib/aws-ssm';
    :
export class LineBotNewIfStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    :
    // Lambda -> Systems Manager
    const ssmChannelSecret = StringParameter.fromSecureStringParameterAttributes(this, 'ssmChannelSecret', {
      parameterName: '/LineAccessInformation/CHANNEL_SECRET',
    });
    ssmChannelSecret.grantRead(lambdaLineBotNewIf);
    const ssmAccessToken = StringParameter.fromSecureStringParameterAttributes(this, 'ssmAccessToken', {
      parameterName: '/LineAccessInformation/ACCESS_TOKEN',
    });
    ssmAccessToken.grantRead(lambdaLineBotNewIf);
    :

Lambda 側にて取得する実装は下記です。

src/lambda/index.ts

import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";

// Systems manager から取得するための諸々
const clientSsm = new SSMClient();
const ssmGetChannelSecretCommand = new GetParameterCommand({
  Name: "/LineAccessInformation/CHANNEL_SECRET",
  WithDecryption: true,
});
const ssmGetAccessTokenCommand = new GetParameterCommand({
  Name: "/LineAccessInformation/ACCESS_TOKEN",
  WithDecryption: true,
});
    :
  const [channelSecret, channelAccessToken] = await Promise.all([
    clientSsm.send(ssmGetChannelSecretCommand),
    clientSsm.send(ssmGetAccessTokenCommand),
  ]);
    :
  /* チャンネルシークレット:channelSecret.Parameter.Value、
     チャンネルアクセストークン:channelAccessToken.Parameter.Value */

await で待つため、ハンドラの中から実行する必要があります。

  1. Lineの署名検証の処理変更

ヘッダを取得する際、bot-sdk のライブラリにおいてLINE_SIGNATURE_HTTP_HEADER_NAMEという定義が作成され、定義の実装を見ると下記になっていました。

@line/bot-sdk/dist/types.d.ts

    :
export declare const LINE_SIGNATURE_HTTP_HEADER_NAME = "x-line-signature";
    :

ヘッダ要素を取得する際はライブラリ側で用意する定義に任せて使うのが良いと思います。

最終ソース

以下のようになりました。

cdk は下記です。
※ハイライト行が変更箇所です。2

lib/line_bot_new_if-stack.ts

import { Stack, StackProps }  from "aws-cdk-lib";
import { Construct } from "constructs";
import { Function, Runtime, Code } from "aws-cdk-lib/aws-lambda";
import { LambdaIntegration, RestApi } from "aws-cdk-lib/aws-apigateway";
import { StringParameter } from "aws-cdk-lib/aws-ssm";

export class LineBotNewIfStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const lambdaLineBotNewIf = new Function(this, "LineBotNewIF", {
      runtime: Runtime.NODEJS_18_X,
      handler: "index.handler",
      code: Code.fromAsset("src/lambda"),
    });

    const apiLineBotNewIf = new RestApi(this, "LineBotAPINewIF", {
      restApiName: "LineBotAPINewIF",
    });
    const lambdaIntegrationLineBotNewAPI = new LambdaIntegration(
      lambdaLineBotNewIf, { proxy: true }
    );
    apiLineBotNewIf.root.addMethod("POST", lambdaIntegrationLineBotNewAPI);

    // Lambda -> Systems Manager
    const ssmChannelSecret = StringParameter.fromSecureStringParameterAttributes(this, "ssmChannelSecret", {
      parameterName: "/LineAccessInformation/CHANNEL_SECRET",
    });
    ssmChannelSecret.grantRead(lambdaLineBotNewIf);
    const ssmAccessToken = StringParameter.fromSecureStringParameterAttributes(this, "ssmAccessToken", {
      parameterName: "/LineAccessInformation/ACCESS_TOKEN",
    });
    ssmAccessToken.grantRead(lambdaLineBotNewIf);

  }
}

Lambda は下記になります。
※ハイライト行が変更箇所です。

src/lambda/index.ts

import { validateSignature, WebhookRequestBody, messagingApi, LINE_SIGNATURE_HTTP_HEADER_NAME } from "@line/bot-sdk";
import { Message }  from "@line/bot-sdk/lib/types";
import { APIGatewayProxyResult, APIGatewayProxyEvent, Context }  from "aws-lambda";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";

// Systems manager から取得するための諸々
const clientSsm = new SSMClient();
const ssmGetChannelSecretCommand = new GetParameterCommand({
  Name: "/LineAccessInformation/CHANNEL_SECRET",
  WithDecryption: true,
});
const ssmGetAccessTokenCommand = new GetParameterCommand({
  Name: "/LineAccessInformation/ACCESS_TOKEN",
  WithDecryption: true,
});

const resultError: APIGatewayProxyResult = {
  statusCode: 500,
  body: "Error"
}
const resultOK: APIGatewayProxyResult = {
  statusCode: 200,
  body: "OK"
}

export const handler = async (eventLambda: APIGatewayProxyEvent, contextLambda: Context) => {
  console.log(JSON.stringify(eventLambda));
  // Systems Manager から値を取得
  const [channelSecret, channelAccessToken] = await Promise.all([
    clientSsm.send(ssmGetChannelSecretCommand),
    clientSsm.send(ssmGetAccessTokenCommand),
  ]);
  const clientLine = new messagingApi.MessagingApiClient({
    channelAccessToken: channelAccessToken.Parameter!.Value || "",
  });
  const stringSignature = eventLambda.headers[LINE_SIGNATURE_HTTP_HEADER_NAME];
  // Line の署名認証
  if(!validateSignature(eventLambda.body!, channelSecret.Parameter!.Value || "", stringSignature!)){
    // 署名検証がエラーの場合はログを出してエラー終了
    console.log("署名認証エラー", stringSignature!);
    return resultError;
  }
  // 文面の解析
  const bodyRequest: WebhookRequestBody = JSON.parse(eventLambda.body!);
  if (typeof bodyRequest.events[0] === "undefined") {
    // LINE Developer による Webhook の検証は events が空配列の body で来るのでその場合は 200 を返す
    console.log("Webhook inspection");
    return resultOK;
  }
  if (bodyRequest.events[0].type !== "message" || bodyRequest.events[0].message.type !== "text"){
    // text ではない場合は終了する
    console.log("本文がテキストではない", bodyRequest);
    return resultError;
  } else {
    // 文面をそのままオウム返しする
    const messageReply: Message = {
      "type": "text",
      "text": bodyRequest.events[0].message.text
    }
    await clientLine.replyMessage({ replyToken: bodyRequest.events[0].replyToken, messages: [messageReply] });
    // OK 返信をセット
    return resultOK;
  }
}

動作確認

簡単なスクリーンショットですが、上手く動作しました。

終わりに

今回は LINE の bod-sdk の新しいバージョンに変更したときの実装例を記載してみました。

ライブラリアップデートは定常的にやっておく必要があるかと思いますので、ご参考になれば幸いです。

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。
「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。
現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。
少しでもご興味あれば、アノテーション株式会社WEBサイト をご覧ください。


  1. ライブラリアップデートを行わなければ問題は起こらない、という考えはゼロデイ等脆弱性のリスクもありますので止めておいた方が良いと思います。この件についても書きたいところではありますが今回は割愛いたします。 
  2. 主観による理由ですがimport * as hoge from "fuga"といった記載は避け、使用するものだけを import で定義するようにしました(Lambda も同様)。