この記事は Salesforce App Cloud Advent Calendar 20日目の記事です。
さて、先日開催された Salesforce World Tour Tokyo(SWTT) において今回も ミニハック があったわけですが、
その中に LINE Bot をテーマとした課題がありました。
課題自体はサンプルコードを自分の Heroku 環境にデプロイして LINE Bot 用のアカウントをちょっと設定するだけでクリアできてしまうため、
アプリ内でどういったことを行っているか全く理解しないまま提出してしまったのですが、
あの Bot はどのように動いているか気になったので、少しだけコードを追ってみました。
サンプルコードはこちらのリポジトリです。
https://github.com/SalesforceDevelopersJapan/salesforce-line-bot-node-sample
(先に結論ですが、本記事では最初の認証フローの部分しか解説していませんできていません)
Salesforce 側の認証
まず、Bot の設定を行って最初に何かメッセージを送ると以下のようなメッセージが届き、/salesforce
にアクセスするよう促されます。
/salesforce
にアクセスしたときの処理を追ってみます。
ここは、Force.com REST API 開発者ガイドで言うところの Web サーバ OAuth 認証フロー になっています。
- index.js 内で app/main.js の
appGetSalesforce()
を呼び出す(ソースコード) - app/main.js 側では
salesforce.getInitialURL()
を取得してリダイレクトしているだけ - app/salesforce.js を見ると getInitialURL は https://login.salesforce.com/services/oauth2/authorize なので OAuth2 認証してるっぽい
- リダイレクト URL は https://***.herokuapp.com/salesforce/callback
- 再度 index.js に戻って
/salesforce/callback
を見ると、今度はmain.appGetSalesforceCallback()
を呼んでる
コールバック(appGetSalesforceCallback()
) の中身はというと、
salesforce.passCertification(req).then(
(jsond) => connectCertificationToCode6(jsond)
).then(
(replyHTML) => sendReply(res, replyHTML)
);
なので、salesforce.passCertification()
を見てみます。
どうやら、
TOKEN_REQUEST_URI = 'https://login.salesforce.com/services/oauth2/token';
に対してリクエストしており、アクセストークンを取得する処理のようです。
認証コード発行
その処理の完了後に呼ばれるのが connectCertificationToCode6()
という関数です。
function connectCertificationToCode6(jsond){
return new Promise (function(resolve, reject){
console.log("ACCESS TOKEN: " + jsond.access_token);
console.log("");
var rand6 = '';
for (i=0; i<6; i++){
rand6 += getRandomIntString(0, 9);
}
var targetDoc = {
fid : jsond.id
};
var insertBody = {
code6 : rand6,
atoken : jsond.access_token,
rtoken : jsond.refresh_token,
me : jsond.id.match(/[0-9A-Za-z]+$/)[0],
instance : jsond.instance_url
};
db.collection(AUTH_C).updateOne(targetDoc, {$set: insertBody}, {upsert: true}, function(err, doc){
if(err){
} else {
resolve('<p style="font-size:45px;margin-top:50px;">以下の認証コード6桁をLINEでメッセージ送信してください。</p><br/><br/><center><p style="font-size:90px">' + rand6 + '</p></center><br/><br/><center><a href="line://" style="font-size:55px">LINE起動</a></center>');
}
});
});
}
若干難しそうですが、ランダムな6桁の認証コードを生成した後、その認証コードやアクセストークン、リフレッシュトークンをまとめてデータベースに保存してるようです。
ここでようやくデータベース何使ってるのか気にしだしたんですが、MongoDB のようです。
※ MongoDBは使ったことないですが、ドキュメント指向データベースと呼ばれ、JSON 形式でデータを格納するみたいですね。
というわけで無事に AUTH_C="auths"
にデータを保存し終えると、認証コードつきの HTML 文字列をゴリゴリ生成してこのメソッドは終了しています。
また呼び出し元に戻ると、
(replyHTML) => sendReply(res, replyHTML)
なので、この HTML を単にクライアント側に返しているようです。
/salesforce
にアクセス後、以下のような画面が表示されるのはそのためですね。
LINE 側の認証
さて、今度はこの認証コードを LINE で送信したときの流れを見てみます。
Bot を含むグループを用意し、そのグループで何かメッセージを送信した場合、Webhook URL に入力した URL にメッセージが POST されます。
今回の場合、/line/callback
を指定していますね。
/line/callback
にアクセスしたときの処理を見ると、/salesforce
などのときと同じくまず index.js 内で main.appPostLineCallback()
が呼ばれます。
appPostLineCallback()
の処理は以下の通りです。
exports.appPostLineCallback = function(req, res){
response200(res);
if(isValidMessage(req.headers['x-line-signature'], req.body)){
if(req.body.events){
for (var i in req.body.events){
var evt = req.body.events[i];
var type = evt.type;
var userId = evt.source.userId;
console.log('TYPE: ' + type);
if(type == 'message'){
console.log('TYPE=MESSAGE');
evtMessage(evt, userId);
} else if (type == 'postback'){
console.log('TYPE=POSTBACK');
evtPostback(evt.replyToken, evt.postback.data, userId);
}
}
}
} else {
console.log('ERROR: INVALID MESSAGE');
}
};
...
function isValidMessage(sign, body){
return sign == crypto.createHmac('sha256', process.env.LINE_CHANNEL_SECRET).update(new Buffer(JSON.stringify(body), 'utf8')).digest('base64');
}
お、何やら複雑なことをやっているように見えますね。ヘッダーの x-line-signature
とはなんでしょう...
さすがに LINE 側の API リファレンスを見ないとわからないので、渋々見てみます。
Signature Validation
リクエストの送信元がLINEであることを確認するために署名検証を行わなくてはなりません。
各リクエストには X-Line-Signature ヘッダが付与されています。
X-Line-Signature ヘッダの値と、Request Body と Channel Secret から計算した Signature が同じものであることをリクエストごとに 必ず検証してください。検証は以下の手順で行います。
- Channel Secretを秘密鍵として、HMAC-SHA256アルゴリズムによりRequest Bodyのダイジェスト値を得る。
- ダイジェスト値をBASE64エンコードした文字列が、Request Headerに付与されたSignatureと一致することを確認する。
というわけで、LINE から送られてきたメッセージに対しては必ずこの処理による検証が必要のようです。
また、API リファレンスからその後の処理についてもわかってきました。
リクエストボディには events
というフィールドで Webhook Event Object という形式のオブジェクトの配列が送られてきます。
Event オブジェクトを含むリクエストボディの例はこんな感じです。(API リファレンスより引用)
{
"events": [
{
"replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
"type": "message",
"timestamp": 1462629479859,
"source": {
"type": "user",
"userId": "U206d25c2ea6bd87c17655609a1c37cb8"
},
"message": {
"id": "325708",
"type": "text",
"text": "Hello, world"
}
}
]
}
すべての Event オブジェクトは type
, timestamp
, source
という 共通のフィールド が必ず含まれます。
source
は送信元となるオブジェクトですが、今回は User です。User には userId
という一意な識別子が含まれています。
また、type
にはイベントの種類を表す文字列が格納されています。
LINE 側で行ったアクションによって、
-
Message Event:
message
-
Postback Event:
postback
-
Follow Event:
follow
- ...
などいくつかバリエーションがあるようです。
さらに、おそらく一番利用するであろう Message Event については、メッセージ内容に応じて Text, Image などの細分化された type が存在するようです。
というわけで、isValidMessage()
の後の処理の部分については、
- リクエストボディには送信された内容が
events
に格納されているはずなので、ループで回す -
event.type
を見て、Message Event または Postback Event の場合にそれぞれevtXXX()
という処理を呼び出す
ということを行っています。
さらに evtMessage()
では Message Event のタイプをチェックし、Text の場合のみ処理(evtMessageText()
) を行っています。
evtMessageText
function evtMessageText(evt, userId, replyToken){
var message = evt.message.text;
console.log('USER: ' + userId);
getStageFromLid(userId).then(
function(result){
var stage = '';
if(result){
stage = result.stage || '';
}
console.log('STAGE: ' + stage);
console.log('MESSAGE: ' + message);
if (stage == 'unknown' || stage == '' || !stage){
stageUnknown(message, userId, replyToken);
} else if (stage == 'codeConfirmed'){
stageCodeConfirmed(message, userId, replyToken);
} else if (stage == 'logined'){
stageLogined(replyToken, userId, message);
}
}
);
}
またぱっと見よくわからないことをやっていますね...
まず、getStageFromLid()
についてですが
function getStageFromLid(lid){
console.log('LINEuser: ' + lid);
return new Promise (function(resolve, reject){
db.collection(USER_C).find({lid: lid}).toArray(function(err, docs){
if (docs[0] && docs[0].stage){
resolve(docs[0]);
} else {
var result = {};
result.stage = 'unknown';
resolve(result);
}
});
});
}
なので、認証のときとは別の USER_C
というデータに対して lid (LINE ID のことかと)でユーザーの情報を取得しています。
初めて呼ばれたときには当然ユーザー情報は存在しないので、 stage='unknown'
として結果が返されています。
そうすると、次に呼ばれるのは stageUnknown()
ですね。
function stageUnknown(message, userId, replyToken){
if(message.match(/^[0-9]{6}$/)){
confirmCode6(message, userId).then(
function(result){
if(result){
changeStage(userId, 'codeConfirmed');
line.codeConfirmed(replyToken);
} else {
line.codeNotConfirmed(replyToken);
}
}
);
} else {
line.stageZero(replyToken);
}
}
...
function confirmCode6(code6, lid){
console.log('CODE: ' + code6 + ' LINEuser: ' + lid);
return new Promise (function(resolve, reject){
db.collection(AUTH_C).find({code6: code6}).toArray(function(err, docs){
if (docs[0] && docs[0].atoken && docs[0].me && docs[0].instance){
db.collection(USER_C).updateOne(
{lid: lid}, {$set:{atoken: docs[0].atoken, rtoken: docs[0].rtoken, fid: docs[0].fid, me: docs[0].me, instance: docs[0].instance}}, {upsert:true}, function(err, doc){
if(err){
resolve(false);
} else {
removeAuthDocument(code6);
resolve(true);
}
});
} else {
resolve(false);
}
});
});
}
6 桁の認証コードが送られてきた場合は confirmCode6()
が呼ばれ、そこでは認証情報を保存した AUTH_C
を検索して
送られてきたコードを検証しているようです。
なるほど、あの6桁の認証コードはどのように発行されて LINE 側で認証しているのかわかってませんでしたが
アプリケーション側で実装していただけだったんですね。
さて、認証コードの検証が済むと
changeStage(userId, 'codeConfirmed');
が呼ばれています。changeStage()
内では USER_C
に格納されたデータを更新しているようです。つまり、
LINE ユーザーの認証ステップを stage と表現し、いまどこまで認証が済んでいるのかを USER_C
に格納しているということですね。
最後の line.codeConfirmed()
については app/line.js 側を見ればよく、
認証コードが確認できました。最後にメールアドレスをメッセージしてください。
というメッセージを送信していることがわかります。
メールアドレスの送信と検証
認証の最後のステップとして、メールアドレスの検証があります。
テキストメッセージが送られてくるところは認証コードのときと変わりないので、evtMessageText()
が呼ばれることになりますね。
今度は stage='unknown'
から stage='codeConfirmed'
に切り替わっているはずなので、stageCodeConfirmed()
が呼ばれます。
function stageCodeConfirmed(message, userId, replyToken){
getMdataFromLid(userId).then(
(result) => salesforce.getMyEmail(result.atoken, result.me, result.instance)
).then(
(email) => confirmEmail(email, message)
).then(
(result) => afterConfirmEmail(result, replyToken, userId)
);
}
getMdataFromLid()
は LINE ID を元にデータベースからユーザー情報を取得している処理です。
その後の salesforce.getMyEmail()
は文字通り、Salesforce にアクセスして User オブジェクトからメールアドレスを引っ張ってきています。(ソースコード)
続く confirmEmail()
では取得したメールアドレスと送られてきたメッセージの突き合わせ作業を行い、最後に
function afterConfirmEmail(result, replyToken, lid){
if (result){
line.home(replyToken);
changeStage(lid, 'logined');
changeState(lid, 'home');
} else {
line.emailNotConfirmed(replyToken);
}
}
なので、メールアドレスが一致していた場合は line.home()
を実行しつつ stage='logined'
に更新を行っているようです。
line.home()
については、このメッセージを表示する処理ですね。(ソースコード)
ここで、表示されているメッセージは単純なテキストではなく、画像&メニューつきのちょっとリッチなメッセージになってますね。
elmTemplate()
を見ると、type に template
を指定していることがわかります。
LINE の API リファレンスを読むと、Template Message と呼ばれるタイプのメッセージだそうです。
タイムアップ!!!
というわけでいよいよここから実際の Bot の機能を掘り下げていこうと思ってたのですが、残念ながらここで力尽きました。。。
これ以降の処理については、いつか後編として記事が書ければ。
一応、ここまでわかったことをまとめると、
-
/salesforce
にアクセスすると、Web サーバ OAuth 認証フロー に沿った Salesforce 側の認証が行われる - 認証が完了すると、LINE 側認証用のランダムな認証コードを発行しつつ、アクセストークン、リフレッシュトークンを MongoDB に保存
- LINE 側から送られてきたメッセージは
/line/callback
というエンドポイントで処理 - 認証コードの突き合わせ作業のあと、メールアドレスの検証も行う。その際 Salesforce の User.Email を使う
認証まわりしか解説できていない上、私がソースコードを読み進めた通りにだらだらと書き散らかしているので非常に読みにくいですが
同じように LINE Bot の実装が気になった、あるいは今後 Salesforce と LINE Bot を連携したサービスを作りたいという方にとって
少しでも参考になれば幸いです。