LINE のオウム返しボットを作ってみた

2023.04.14

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

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

はじめに

オンボーディングで行ったものに「LINE オウム返しボットを作成する」というのがありまして、いろいろと試行錯誤の末に行ったことをブログ化いたします。
同じような内容で躓かれている方がいらっしゃったらご参考になればと思います。

与えられた要件

下記になります。

  • AWS のインフラは AWS Cloud Development Kit(CDK)を使用して IaC 化する。
  • バックエンドは Node.js の Lambda を利用する。
  • API は API Gateway か GraphQL のどちらかを選択して利用する。
  • テキストメッセージの内容をそのまま返信する。
  • ネットから全ソース丸々コピペは禁止。

このブログで行うこと

  • 方針の整理
  • LINE Messaging API 接続情報の取得
  • 実装
  • 動作確認

方針の整理

与えられた要件を元に、ざっくりと方針を整理しました。

  • 実装するスクリプトは TypeScript で行う。
  • Node.js のバージョンは 18.x にする。
  • API は API Gateway を選択する。(筆者が手慣れているだけという消極的な理由)
  • (演習にならないので)弊社ブログの この記事 内容を丸々コピペせず、筆者なりに

LINE Messaging API 接続情報の取得

LINE の Messaging API を使用するためには、このページ のように Messaging API 用のチャネルを作成する必要があります。

その後、以下の2つのコードを取得しておきます。

  • チャネルアクセストークン(Messaging API設定から取得)
  • チャネルシークレット(チャネルの基本設定から取得)

実装

実装は AWS CDK によるワークショップ を参考にいたしました。

CDK による環境構築

環境構築は下記のコマンドで行います。

$ mkdir LineBotTest
$ cd LineBotTest
$ cdk init app --language typescript

環境構築を行うと、いくつかのファイルが自動で生成されますが、bin と lib ディレクトリ配下に ts の拡張子のファイルが出来ます。

今回は「LineBotTest」というディレクトリ配下で生成したため「line_bot_test」というファイル名が付きましたが、lib ディレクトリにある stack の名前が記載されているファイルが CloudFormation へ定義する TypeScript ソースになります。

CloudFormation へ定義するテンプレートの実装

それでは、テンプレートを実装します。
ざっくり、以下のような流れで定義します。

  1. Lambda 関数の定義
  2. API Gateway の定義
  3. API Gateway へ POST の RestAPI を定義し実行イベントを Lambda 関数へ紐付け

組んだ内容が下記になります。
なお、「ACCESS_TOKEN」の箇所は前項のチャネルアクセストークンの文字を、「CHENNEL_SECRET」の箇所は前項のチャネルシークレットの文字を、それぞれ設定します。

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);

    // The code that defines your stack goes here

    // example resource
    // 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 については「src/lambda」ディレクトリ配下に「index.ts」というファイル名で作る想定としました。

Lambda 関数へ定義する関数の実装

それでは 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

おおまかに処理の流れを記載すると下記になります。

  1. LINE 署名検証を validateSignature 関数 を使ってチェックする
  2. 文面がテキストかどうかチェックする
  3. replyMessage 関数 を使って応答メッセージを送る

これらを踏まえたTypeScript ソースは下記になります。

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));
    const stringSignature = eventLambda.headers["X-Line-Signature"];
console.log("Line.validateSignature");
    // Line の署名認証
    if(!Line.validateSignature(eventLambda.body!, client.config.channelSecret!, stringSignature!)){
        // 署名検証がエラーの場合はログを出してエラー終了
        console.log("署名認証エラー", stringSignature!);
        return resultError;
    }
    // 文面の解析
console.log("JSON.parse to eventLambda.body");
    const bodyRequest: Line.WebhookRequestBody = JSON.parse(eventLambda.body!);
    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
        }
console.log("replyMessage", messageReply);
        client.replyMessage(bodyRequest.events[0].replyToken, messageReply);
        // OK 返信をセット
console.log("resuleOK send.");
        return resultOK;
    }
}

何処まで通ったか、をデバッグしたかったのでいくつか console.log を入れています。
お気づきの方もいらっしゃるかと思いますが、このソース、実はこのままでは動かず要修正箇所が3箇所あります。
次項にて動作確認を行い修正していきます。

動作確認

ビルド・デプロイ

ビルド、デプロイは下記で行います。
VSCode で実装していると、前もってビルドエラーを検知してくれるので便利ですね。

$ npm run build
$ cdk synth ※初回のみ
$ cdk bootstrap ※初回のみ
$ cdk deploy

デプロイ時の画面は下記のようになっていますが、エンドポイントを指定している URL は控えておきます。

$ cdk deploy

✨  Synthesis time: 2.81s

LineBotTestStack: building assets...
中略
Outputs:
LineBotTestStack.LineParrotingApiEndpointXXXXXXXX = https://xxxxxxxxxx.execute-api.yy-yyyyyyyyy-1.amazonaws.com/prod/
Stack ARN:
arn:aws:cloudformation:...

✨  Total time: 35.47s

%

Messaging API の Webhook URL へ設定する必要がありますので、下記画面で「編集」をクリックし、控えておいた URL を設定します。

Messaging API 検証

前項で編集した Messaging API の Webhook URL 設定画面には「検証」ボタンもあります。
ここから導通確認ができます。

押すと…エラーになってますね。

それでは CloudWatch を見てみましょう。

画面を見ると、どうやら validateSignature 関数でランタイムエラーが発生しているようです。

CloudWatch のログの表示にはそれぞれ「コピー」ボタンが付いていてメッセージを取得出来ますが、今回のメッセージを取得した内容が下記になります。

Invoke Error     {
    "errorType": "TypeError",
    "errorMessage": "The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received undefined",
    "code": "ERR_INVALID_ARG_TYPE",
    "stack": [
        中略
        "    at Runtime.handler (/var/task/index.js:22:15)",
        "    at Runtime.handleOnceNonStreaming (file:///var/runtime/index.mjs:1085:29)"
    ]
}

更に関数の先頭でログに出力される内容もコピペして見ると…

INFO {
    "resource": "/",
    "path": "/",
    "httpMethod": "POST",
    "headers": {
        "CloudFront-Forwarded-Proto": "https",
         :
        中略
         :
        "x-line-signature": "XXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    },
     :
    中略
     :
}

署名のヘッダが「x-line-signature」で来ていました。
ソースはサンプルいくつかのサンプルを参考に「X-Line-Signature」としていましたが、仕様が小文字に変わったようです。

Messaging API 仕様書の記載 を見ると、

リクエストヘッダーのフィールド名は、大文字・小文字の表記が予告なく変更される可能性があります。Webhookを受け取るボットサーバー側では、ヘッダーフィールド名の大文字小文字を区別せずに扱ってください。

…という記載があり、LINE 署名のヘッダ文字は仕様が変わる可能性がある、ということですね。

修正1. LINE の署名検証の修正

それではこの部分を修正します。

方針としては、ハンドラに入ってきたイベント引数を一度文字に変え、「X−LINE-SIGNATURE」は大文字小文字区別なく「X-Line-Signature」へ replace をかけ、その文字列を JSON パースし直して取得するようにします。

こんな感じ。

    :
export const handler = async (eventLambda: Lambda.APIGatewayProxyEvent, contextLambda: Lambda.Context): Promise => {
    console.log(JSON.stringify(eventLambda));
    const structHeader = JSON.parse(JSON.stringify(eventLambda.headers).replace(/X-Line-Signature/gi, "X-Line-Signature"));
    const stringSignature = structHeader["X-Line-Signature"];
    :

ビルド、デプロイしたものを改めて検証すると…

まだ、エラーのようですので、改めて CloudWatch のログを見ましょう。

また Invoke Error のようで、CloudWatch のログを展開してみますと、

Invoke Error     {
    "errorType": "TypeError",
    "errorMessage": "Cannot read properties of undefined (reading 'type')",
    "stack": [
        "TypeError: Cannot read properties of undefined (reading 'type')",
        "    at Runtime.handler (/var/task/index.js:31:31)",
        "    at Runtime.handleOnceNonStreaming (file:///var/runtime/index.mjs:1085:29)"
    ]
}

下記の文の if 文のようですね。

    :
    if (bodyRequest.events[0].type !== 'message' || bodyRequest.events[0].message.type !== 'text'){
    :

引数のログにおける body の中身を見ると events が空配列でした。

INFO {
         :
        中略
         :
    "body": "{\"destination\":\"XXXXXXXXXXXXXXXXXXXXXXXXXXXX\",\"events\":[]}",
    "isBase64Encoded": false
}

修正2. Messaging API 検証からの独自 body による修正

修正の方針ですが「events が空」、すなわち「events[0] の typeof が undefined」であれば、この場合は 200 のステータスコードを返すようにします。

    :
    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'){
    :

ビルド、デプロイして改めて検証すると…

ようやく OK が出ました。

LINE クライアントから実際に送ってみる

それでは LINE のアプリケーションから送ってみましょう。

あれ?
返事がない。ただのしかばねのようだ。

CloudWatch のログを見ると、無事に終わっていそうですが…。result のスペルが間違っているのはご愛嬌ということで。

結論を言いますと、ハンドラが非同期関数で実装しているので、下記のように replyMessage 関数に戻りを待つ await 演算子が必要になります。

    :
console.log("replyMessage", messageReply);
        await client.replyMessage(bodyRequest.events[0].replyToken, messageReply);
    :

ビルドしてデプロイすると、無事、オウム返し出来ました。

おわりに

今回は LINE のオウム返しボットを作成しました。
最終的な 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;
    }
}

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

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