プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

LINE WORKS で予約投稿ボットを作成する

訳あって、 LINE WORKS でボットを作ることになりました。今回試すのは、予約投稿を可能にするボットです。

予約投稿といっても、別段難しい話ではなく、

  • ユーザーが LINE WORKS 上で予約投稿ボットに投稿
  • 一定時間後に宛先ユーザーに予約投稿ボットから投稿される

という動作をするものです。いわゆる言ったことをそのまま返す、オウム返しボットの応用になります。

今回は予約投稿ができることを確認するためのサンプルなので、

  • 宛先ユーザーやボットからの送信日時を、ボットとの対話を通じて指定する機能
  • 予約投稿の確認やキャンセル

などは省略しています。投稿した内容を一定の固定時間おいて宛先ユーザー(これも固定)に配信するだけのサンプルになります。

このサンプルを作った顛末を参考までにメモっと来ます。

なお、元ネタとしては、下記のブログで紹介されている記事を参考にさせていただきました。ありがとうございました。

LINE WORKSで初めてのBot開発!(前編) - Qiita

LINE WORKSで初めてのBot開発!(後編) - Qiita

構成

今回作るものの構成はこんな感じです。

f:id:junichim:20190611172647p:plain

cacoo で描きました)

ユーザーが予約投稿ボットに投稿すると、同じ内容を別ユーザーに時間をおいて投稿します。

ボットが通信する送受信サーバーとして、 AWS Lambda を使います。予約投稿が終わるまで Lambda を実行中にするわけにはいかないので、 CloudWatch Events に登録し、指定時間に Lambda を呼び出すようにして、投稿処理を行いたいと思います。

とりあえず思いついたのが上記の構成なので、もっといい方法があるよ、という方、ぜひ教えてください。

準備

まずは、LINE WORKS のボット API を使うための準備をします。LINE WORKS をまだ使ってなければ、無料プランがあるので、申し込みましょう。

ボットAPIを使うための準備

LINE WORKS の申請をしたのが自分自身なら、最初から Developer の権限を持っていると思いますので、 LINE WORKS Developer Console にログインできると思います。

もし、会社や組織等で LINE WORKS を使っており、自分のアカウントに Developer の権限がない場合、Developer Console にログインできません。その場合は、管理者の方に相談して、ログインできる権限をもらってください。

LINE WORKS Developer Console にログインしたら、APIのメニューより、API ID / Server API Consumer Key を発行しておきます。

f:id:junichim:20190612103929p:plain

あと、LINE WORKS のAPIを使うためには、サーバートークンが必要になりますが、これは、サーバーの登録方法により発行方法が異なっています。

サーバーの登録方法は、固定IPとID登録の2種類があります。 今回はサーバーとして Lambda を使うので、固定IPタイプではなく、ID登録タイプを選択します。

ID登録タイプの場合、サーバーの登録(Server List への登録)は、先ほど発行した Server API Consumer Key を登録し、サーバーIDと認証キー(秘密鍵)を発行します。 サーバー名は好きなように指定します。これは自分がサーバーを区別するために使うだけで、認証等では使わないようです。

f:id:junichim:20190612104132p:plain

最終的にサーバートークンは、これらを用いて、JWT (Json Web Toekn) を利用し、専用APIを呼ぶことにより発行しますが、この部分はボットAPI利用時に Lambda 側で行う処理になります。

ということで、Developer Console ではサーバーIDと秘密鍵の発行までやっておけばOKです。 なお、このあたりの一連の手順は、下記公式ドキュメントに詳しく載っていますので、ぜひご覧ください。

API 認証の準備

ボットの登録

次に、LINE WORKS 上にボットを登録します。

LINE WORKS は1契約が1テナントという単位で管理されています(こちらの料金のページにある『Q.ユーザー/メンバーとライセンスの違いを教えてください。』にテナントについての説明があります)。

1テナントがさらに複数のドメインという単位に分かれているそうですが、通常は1テナント1ドメインなので、気にしなくていいようです(グループ企業全体で1テナント、子会社単位で複数ドメインというのがあるそうですが、特殊なケースだそうです。詳しくはこちらの『Botとドメインの関係につきまして』をご覧ください)。

LINE WORKS のボットは、 LINE のボットと異なり、LINE WORKS の管理者(テナントとドメインの管理者)がボットを登録すれば、そのドメインのユーザー全員が使えるようになります(正しくはボットを使うユーザーを管理者が指定でき、最大の範囲が全員になります)。

ということで、最初にテナントに対して登録し、次にドメインに登録します。

テナントに登録

テナントに対するボットの登録は、 LINE WORKS Developer Console でボットを登録すればOKです。 Botメニューを開き、『登録』ボタンを押すと、入力画面が表示されるので、ボット名など必要な項目を入力して、『保存』ボタンを押せば完了です。

f:id:junichim:20190611220805p:plain

なお、この時点では、 Callback URL (ボットがユーザーからの投稿も受け付けた際に呼び出すURL)がまだ未定ですので、Off のままにしておきます(あとから変更できます)。 また、『ボットポリシー』の『トークルームへ招待』は招待不可としておきます(各ユーザーと予約投稿ボットのやり取りになるので)。

ドメインに登録

次に、ボットをドメインに登録します。

LINE WORKS にログインし、管理者画面を開きます。上部のメニューより『サービス』を選択し、左側のメニューより『Bot』を選びます。

f:id:junichim:20190611221009p:plain

追加ボタンを押すと、Developer Console で登録したボットが表示されますので、選択して追加を行ってください。

テナント、ドメインや登録したボットの状態に関する説明は、下記公式ドキュメントをご覧ください。 トークBot APIの概要 - トークBot - LINE WORKS Developers Document

メッセージ受信処理

さて、ここまでくれば準備OKです。まずは、ボットからのメッセージ受信処理を作成します。

ユーザーからの投稿を LINE WORKS ボットが受け取り、その投稿(メッセージ)がボット登録時に指定した Callback URL に POST されます。 なので、受信メッセージサーバーを立てて、POST でメッセージを受け取るようにします。

これは具体的には API Gateway と Lambda で実装します。

Lambda 関数の作成

受信メッセージを処理する Lambda 関数を適当な名前で作成します(今回は reservePost としました)。 ランタイムは、 Node.js 10.x を選んでおきます。

実行ロールは、IAM を使って事前に作成したものを指定します。 例えば、今回は、 下記のように

f:id:junichim:20190611224026p:plain

  • AWSLambdaBasicExecutionRole
  • CloudWatchEventsFullAccess

というポリシーを持つロールを作成して、これを指定しました。

とりあえず、中身は未実装にしておき、API Gateway の設定を先にします。

API Gateway

API Gateway のほうは適当なリソース名を入力し、POSTメソッドを追加します。

f:id:junichim:20190611222830p:plain

統合タイプに Lambda 関数を指定し、『Lambda プロキシ統合』にチェックを入れ、Lambda関数を指定しておきます。

設定ができたら、API のデプロイを行います。デプロイができたら、URLが表示されますので、それを LINE WORKS Developer Console のボット登録を行った画面で編集を行い、 Callback URL として設定します。

LINE WORKS ボットからのメッセージを受け付ける Lambda 関数の実装

これで、LINE WORKS ボットがメッセージを受け取ると、 Callback URL ( API Gateway ) を通じて、Lambdaが呼ばれるようになりました。 この受信メッセージ用のLambdaでは、主に次の処理を行います。

  1. 受信メッセージのチェック(改ざん確認)
  2. 予約投稿したい内容の取得
  3. CloudWatch Events へ登録
受信メッセージのチェック

最初に行うのは受信メッセージのチェックです。 受け取ったメッセージが正しく LINE WORKS ボットから送られたものであるか(改ざんがないか)を確認します。

確認方法を公式ドキュメントで見ると、X-WORKS-Signature のヘッダを使うとの記述があります。

これについては、 API Gateway から Lambda に送られてくるパラメータにどういう形で設定されているか一度確認しておきます。 Lambdaの先頭に、

    console.log("event: " + JSON.stringify(event));

とログを埋め込んでおきます。

この状態で、 LINE WORKS にログインして、予約投稿ボットにメッセージを送ると、

f:id:junichim:20190611224955p:plain

下記のように Lambda のログ(CloudWatch Logs)に event の内容が出力されているのがわかります。

    "body": "{\"type\":\"message\",\"source\":{\"accountId\":\"LINE WORKS のユーザーID\"},\"createdTime\":1559921558817,\"content\":{\"type\":\"text\",\"text\":\"てst2\"}}",

また、headers に

    "headers": {
        ・・・
        "x-works-botno": "nnnnnn",
        "x-works-signature": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    },

と追加ヘッダが存在しているのがわかります。

使うべき情報されわかれば、あとは公式ドキュメントの通りに処理を実装します。

const crypto = require("crypto");

function isValidMessage(body, signiture) {
    const hmac = crypto.createHmac("sha256", API_ID);
    hmac.update(body);
    const str = hmac.digest('base64');

    console.log("message body: " + body);
    console.log("message hmac: " + str);
    console.log("signiture   : " + signiture);

    return str === signiture;
}

これで、メッセージの改ざんチェックができるようになりました。

(参考) [Sy] HMAC-SHA-256 を計算するサンプル(JavaScript) | Syntax Error.

予約投稿したい内容の取得

event.body は文字列なので、parseして、JSONオブジェクトに変換しておきます。

    const body = JSON.parse(body_str);

body オブジェクトに type プロパティがあります。これの値で送られてきたメッセージの種類が判別できます。

最初にボットとの会話を開始すると type が join / joined になったメッセージもやってきます。 ここでは、 type が message のもの(一般のメッセージ)を対象にします。message の場合は、

  • body.source.accountID に送信者のID
  • body.content.text に送られたメッセージの内容

などにほしい情報があるので、これらを取得しておきます。詳しくは公式ドキュメントをご覧ください。

なお、今回は、予約投稿を実施する日時、送信先は固定で扱ってるのでここでは取得していません。

CloudWatch Events への登録

受信処理の最後は、指定された日時に送信用メッセージサーバ(Lambda関数)を呼び出すように、CloudWatch Events を構成します。

CloudWatch Events をAPI で生成するには、

  • putRule を呼び出して、ルールを作成
  • putTargets を呼び出して、ルールに対してターゲット(Lambda関数)を指定

という流れになります。

ルールの作成

ルールは、指定時間に呼び出したいので、cron式で指定します。 例えば、現在時刻の3分後に投稿するならば、

function getCronExpression() {
    // cron 式の生成
    // UTC
    const nw = new Date(Date.now() + 3 * 60 * 1000); // 3分後

    const SP = " ";
    const minute = nw.getUTCMinutes();
    const hour = nw.getUTCHours();
    const date = nw.getUTCDate();
    const month = nw.getUTCMonth() + 1;
    const year = nw.getUTCFullYear();
    return "cron(" + minute + SP + hour + SP + date + SP + month + SP + "?" + SP + year + ")";
}

のようにcron式を作ればOKです。なお、cron式の日時指定はUTCになります。

このcron式を使って、putRule呼び出し用のパラメータを構成します。

async function createCloudWatchRule() {
    let cwe = new AWS.CloudWatchEvents();
    let params = {
        Name: 'post_reservation', /* required */
        Description: 'LINE WORKS post reservation, from, to, at,',
        ScheduleExpression: getCronExpression(),
        State: "ENABLED"
    };

Name はルールの名前になります。ルール削除時にも使います。

また、同じルール名を指定して putRule を呼ぶと、エラーになるのではなく、既存のルールが更新されます。このため、複数の予約投稿を扱いたい場合はルール名が一意になるように工夫する必要があります(今回は未対応です)。

ScheduleExpression にcron式を指定します。

パラメータが設定できれば、あとはputRuleを呼び出すだけです。

    return new Promise((resolve, reject) => {
        cwe.putRule(params, function(err, data) {
            if (err) {
                console.error("createCloudWatchRule err: " + JSON.stringify(err), err.stack);
                reject(err);
            } else {
                console.log("createCloudWatchRule: " + JSON.stringify(data));
                resolve(params.Name); // ルール名を返す
            }
        });
    });
}
ターゲットの指定

ターゲットの指定も同様に行います。

まず、ターゲット指定用のパラメータを作成します。

Arn に呼び出したい Lambda 関数のARNを指定します。Id にこのターゲットを特定するIDを指定します。putRuleと同様に同じIDを指定して putTarget を呼ぶと、設定内容が更新されます。また、IDを変えれば複数の Lambda 関数呼び出しを登録することもできます(同じ Lambda関数でもOK)。

async function assignRuleToLambda(rule_name, id_from, id_to, message) {
    let cwe = new AWS.CloudWatchEvents();
    let params = {
        Rule: rule_name,
        Targets: [
          {
            Arn: LAMBDA_ARN_SEND,
            Id: '1', /* required */
            Input: "{ \"rule_name\": \"" + rule_name + "\", \"id_from\": \"" + id_from + "\", \"id_to\": \"" + id_to + "\", \"message\": \"" + message + "\" }",
          }
        ]
    };

また、今回はサンプル実装のため、投稿する内容、宛先などの情報を Lambda 関数に対するパラメータとして渡しています。 これは、 Input 要素に JSON 文字列として記述することで渡すことができます。 パラメータの受け渡しには、 Input, InputPath, InputTransformer の3種類が使えます。詳しくはAWSのドキュメントをご覧ください。

あと、後述するように投稿完了後 CloudWatch Events のルールを削除する必要があるので、このルール名もパラメータとして渡しておきます。

パラメータができれば、 putTargets を呼び出せばOKです。

    return new Promise((resolve, reject) => {
        cwe.putTargets(params, function(err, data) {
            if (err) {
                console.error("assignRuleToLambda error: " + JSON.stringify(err), err.stack);
                reject(err);
            } else {
                console.log("assignRuleToLambda: " + JSON.stringify(data));
                if (data.FailedEntryCount != 0) {
                    console.error("assignRuleToLambda has error: " + JSON.stringify(data));
                    reject(data);
                }
                resolve(data);
            }
          });    
    });      
}

putTarets の戻り値には FailedEntryCount があり、これが1以上になっているとどれかのターゲット登録(または更新)に失敗したことを意味します。 上記では、FailedEntryCount が0であることを確認しています。

(参考)

CloudWatch から呼び出され、 LINE WORKS ボットへ投稿を行う Lambda 関数の実装

次に、CloudWatchEvents から呼び出され、LINE WORKS ボットへ投稿を行う Lambda 関数を実装していきます。 この送信メッセージ用のLambdaでは、主に次の処理を行います。

  1. パラメータの取得
  2. サーバートークンの取得
  3. LINE WORKS ボットへ送信
  4. CloudWatch Event Rule の削除

パラメータの取得

CloudWatchEvents のターゲット登録の際に Input 要素で指定していたパラメータを取得します。

呼び出された Lambda 関数の event オブジェクトがそのままInputで指定した JSON になっています。

    // パラメータ取得
    const id_from = event.id_from;
    const id_to = event.id_to;
    const org_msg = event.message;
    const rule_name = event.rule_name;

サーバートークンの取得

LINE WORKS Developer の公式ドキュメントにのっとって処理します。

まず、JWTを取得します。

const jwt = require('jsonwebtoken');

function getJwt() {
    const iat = Math.floor(Date.now() / 1000); // msec-> sec
    const exp = iat + (60 * 30); // 30分後
    let token = jwt.sign({
        iss: SERVER_ID,
        iat: iat,
        exp: exp
    }, PRIVATE_KEY, {algorithm: 'RS256'});
    return token;
}

次に、サーバートークン取得用のAPIを呼び出します。

const request = require("request-promise");

async function getServerToken() {
    const jwtoken = getJwt();
    console.log("jwtoken: " + jwtoken);

    const headers = {
        "Content-type": "application/x-www-form-uelencoded; charset=UTF-8"
    };
    const options = {
        url : TOKEN_URL,
        method : "POST",
        headers : headers,
        form : {
            "grant_type": encodeURIComponent("urn:ietf:params:oauth:grant-type:jwt-bearer"),
            "assertion" : jwtoken
        },
        json : true
    };

    return request(options)
        .then((body) => {
            console.log("getServerToken:" + JSON.stringify(body));
            return body.access_token;
        });
}

問題がなければ、サーバートークンが取得できます。

(参考)

LINE WORKS ボットへ送信

サーバートークンさえ取得できれば、あとは LINE WORKS ボットAPIを呼び出すだけです。

ヘッダにServer API Consumer Key と取得したサーバートークンを指定します。

async function sendMessage(serverToken, dst_id, org_msg) {
    const headers = {
        "Content-type": "application/json; charset=UTF-8",
        "consumerKey": SERVER_CONSUMER_KEY,
        "Authorization": "Bearer " + serverToken
    };

投稿する内容、ヘッダ、ボットNoを指定したオブジェクトを作成し、

    const options = {
        url : SEND_URL,
        method : "POST",
        headers : headers,
        json : {
            "botNo": Number(BOT_NO),
            "accountId": dst_id,
            "content": {
                "type": "text",
                "text": org_msg
            }
        }
    };

request-promise モジュールを使って送信用のボットAPIを呼び出せばOKです。

    return request(options)
        .then((body) => {
            if (body.code != 200) {
                console.error("sendMessage error: " + JSON.stringify(body));
                throw new Error(body.errorMessage);
            }
            console.log("sendMessage success: " + JSON.stringify(body));
            return "finish";
        });

}

CloudWatch Events Rule の削除

送信が無事に終わったら、 CloudWatch Events のルールを削除しておきます。ルールの削除は

  1. ルールに含まれるターゲットをすべて削除
  2. ルールの削除

の順番で行う必要があります。実際には、ルールに含まれるすべてのターゲットを指定するためにターゲットのリストアップも必要になるため、それぞれ

  1. ルールに含まれるすべてのターゲットIDを取得: CloudWatchEvents#listTargetsByRule
  2. ルールに含まれるターゲットをすべて削除: CloudWatchEvents#removeTargets
  3. ルールの削除: CloudWatchEvents#deleteRule

のAPIを呼び出して実行すればOKです。ここにはコードは載せませんが、気になる方は GitHub リポジトリのほうを見てください。

実行

これで一通りの実装が終わりました。テストしてみます。

LINE WORKS にログインして、一人のユーザーから

f:id:junichim:20190612092638p:plain

『ブログ用にテスト』という内容で予約送信ボットに投稿を投げました。

するとおおよそ3分後、別のユーザーに対して

f:id:junichim:20190612093841p:plain

のように投稿が送られてきました。

投稿が行われる前に、AWS にログインして CLoudWatch Events を見ると、ルールが

f:id:junichim:20190612101345p:plain

のように設定されており、ターゲットも、

f:id:junichim:20190612101456p:plain

のように追加されていました。もちろん、投稿が終わると削除されました。

うまく動作しているようです。

今後

さて、ここまでで予約投稿を行うことはできたのですが、最初に書いたように実際に使えるようにするためには、ボットとの対話が欲しいところです。

今回の予約投稿だと、

  • 宛先
  • 送信日時
  • 送りたい内容

の3項目を入力する必要があります。これだけなら、自然言語処理とか入れずに自分で実装することもできるかな?と一瞬思ったのですが、やはりそれなりに大変そうなのでやめました。

そういうわけで、ボット用のフレームワークを探しているところです。 最近、AWS ばっかり使って Amazon にベンダーロックインされている身としては、Amazon Lex を使いたいところなのですが、Lex は日本語に対応していないとのこと。 なので、この先どう進めるか思案中です。

今のところ DialogFlow が有力かな?そのあたりは実装したらまた記事にまとめたいと思います。

あ、ソースコード一式を GitHub に置きました。

github.com

もしご興味があれば試してみてください。

補足

実装時の注意点を最後にいくつか補足しておきます。

Lambda の環境変数でのプライベートキーの指定方法について

今回実装した Lambda 関数では、サーバートークンを取得するための秘密鍵を環境変数で与えています。 秘密鍵を環境変数で与える際には、改行文字も正しく与える必要があるようです。

なので、下記記事などを参考に、

[RS256] JWTでRSA秘密鍵を環境変数で処理したい [Javascript] - Qiita

$ cat private.key | sed -e :xxx -e 'N; $! b xxx' -e 's/\n/\\n/g'

(xxxはラベル名なのでなんでもOKです)

として、1行化し、Lambda のコンソールから環境変数に設定します(コンソールで入力する際はダブルクォーテーションで囲む必要ありません)。

Lambda 側では

const PRIVATE_KEY = process.env.LINE_WORKS_PRIVATE_KEY.replace(/\\n/g, "\n");

のようにして、1行化した文字列を複数行に直しています。

(参考) sed のラベルコマンドなどについて詳しく解説してくれています。

sed ファイル内の改行を削除, 連続した空白を1つにする - Qiita

Lambda 関数のローカル開発について

こちらの記事にあるように Lambda の開発では index.js を呼び出す proxy.js などを作成し、そちらで dotenv を使って環境変数を定義すると、Lambda にアップロードするコードを変更することなく、ローカルでも

$ node proxy.js

のように実行できる、という形が作れます。

API Gateway からの呼び出しを模擬したければ proxy.js 内で定義している event に API Gateway から送られてくる event を張り付けてやればよいかと思います(eventの内容は一度 API Gateway 経由で Lambda を動かし、 CloudWatch Logs で確認すればよいかと思います)。

とはいえ、毎回、API Gateway からの呼び出しを模擬するのも面倒だったので、途中からはローカルの開発環境ではパッケージのインストールぐらいしか行ってなかったんですが、手元でさっと動かせるのは便利でした。

ご参考までに。