LINEボットでPocketに登録したお気に入りサイトを参照する

Pocketはご存じでしょうか。
PCやスマホから、気に入ったWebサイトをブックマークとして保存し、共有できるサービスです。

https://getpocket.com/ja/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a1fe3576-87ed-446b-9513-021bd61b4116.png)


今回は、このPocketに登録したサイトをLINEボットから参照できるようにします。

最終的には以下のような画面になります。
まずこちらが、LINEボットを友達登録した後に、最新のサイトリストを表示させたときのものです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/5eac2f0d-cb19-33ec-0e97-e1d34226307e.png)

次に、こちらが、LIFFアプリとして表示させたときのものです。LIFFは、LINEアプリ内で起動させる方法と、普段お使いのWebブラウザから起動させる方法のどちらでも可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/45398fcf-ff8a-dc72-6c7b-9ff691dd26b5.png)

構成としては以下の通りです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/64fffb0f-e30b-c5ce-b8e4-14b9283a4b76.png)

ちょっとやり取りが多いですが、のちほど一つ一つ説明していきます。

ソースコードもろもろは以下に置いておきました。

poruruba/PocketLiff

https://github.com/poruruba/PocketLiff

# 準備

これらを実現するための前準備は以下の通りです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e678759b-7cca-e901-912c-94bf6c4f324e.png)

## LINE：プロバイダの作成

LINEのプロバイダが必要です。まだ作成していない場合は、以下のサイトから作成しておきます。

LINE Developersコンソール

https://developers.line.biz/console/

## LINE：チャネルの作成

LINE Developersコンソールから、作成したプロバイダにおいて、チャネルを作成します。チャネルの種類は「Messaging API」を選択します。このチャネルは、LINEボット用です。
いくつか入力を促されますが、適当に入力して作成します。
その後、いくつかの設定が必要です。

Messaging API設定から、チャネルアクセストークン(長期)を発行しておきます。
Webhook URLにこれから立ち上げるサイトを登録します。

　```https://[立ち上げるNode.jsサーバのホスト名:ポート番号]/pocket-line```

まだNode.jsサーバを立ち上げていないので、「検証」ボタンを押下してもNGとなります。
Webhookの利用をOnにします。
LINE公式アカウント機能の応答メッセージを無効に変えておきます。

ということで、最後に以下の情報をメモっておきます。
・チャネルシークレット
・チャネルアクセストークン(長期)
・QRコード(友達登録用)

次に、もう一つチャネルを作成します。
チャネルの種類は「LINEログイン」です。こちらは、LIFF用です。

アプリタイプには、「ウェブアプリ」にチェックを入れておきます。
最後に、よければ「公開済み」に状態を変更しておきます。

ということで、最後に以下の情報をメモっておきます。
・チャネルID

## LINE：LIFF登録

次にLIFFを登録します。
LIFFは、LINEログインのチャネルに登録します。

サイズには「Full」を選択するとよいでしょう。
エンドポイントURLには、これから立ち上げるNode.jsサーバを指定します。
（もちろんまだ実体を立ち上げていませんが）

　```https://[立ち上げるNode.jsサーバのホスト名:ポート番号]/pocket_liff/index.html```

スコープには、以下のチェックをOnにします。
・profile
・openid

ということで、最後に以下の情報をメモっておきます。
・LIFF ID
・LIFF URL

## LINE：リッチメニュー登録

LINEアプリで使いやすいようにするため、リッチメニューを登録します。
以下をご参照ください。

　[LINEBotのリッチメニューエディタがないので自作した](https://qiita.com/poruruba/items/adee5be5eefa83a35962)

Bodyはこんな感じです。

```json
{
	"richMenuId": "richmenu-XXXXXXXXXXXXXXXXXXXXXXXXXX",
	"name": "Pocketショートカットメニュー",
	"size": {
		"width": 2500,
		"height": 250
	},
	"chatBarText": "ショートカット",
	"selected": true,
	"areas": [
		{
			"bounds": {
				"x": 44,
				"y": 44,
				"width": 783,
				"height": 170
			},
			"action": {
				"type": "uri",
				"uri": "https://liff.line.me/【LIFF-ID】"
			}
		},
		{
			"bounds": {
				"x": 869,
				"y": 44,
				"width": 783,
				"height": 170
			},
			"action": {
				"type": "postback",
				"data": "menu_help"
			}
		},
		{
			"bounds": {
				"x": 1694,
				"y": 44,
				"width": 783,
				"height": 170
			},
			"action": {
				"type": "postback",
				"data": "menu_latest"
			}
		}
	]
}
```

もしくは、以下の、LINE Official Account Managerから登録します。

https://manager.line.biz/

メニューはこんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/000cca15-c54f-2679-b370-cd11dab43270.png)

アプリボタンをクリックと、LIFF起動のためのURLが返るようにしましたので、LIFFが起動します。
ヘルプと最新リストをクリックすると、ポストバックが返るようにしましたので、のちほど立ち上げるNode.jsで対応する処理を実装します。

ということで、最後に以下の情報をメモっておきます。
・リッチメニューID

## Pocket：Pocketアプリ登録

Pocketにアプリを登録します。そのための管理コンソールがあります。

https://getpocket.com/developer/apps/

「CREATE AN APPLICATION」ボタンを押下すると、Pocketアプリを登録できます。
Permissionsには、Retrieveにチェックを入れておきます。
Platformsには、Webにチェックを入れておきます。

作成後、URLを指定します。URLには、LINEのLIFF URLを指定します。
作成が完了すると、Consumer Keyが払いだされますので、それをメモっておきます。

## Node.jsサーバ立ち上げ

もろもろのソースコードを上げていますので、ZIPダウンロード後以下のようにセットアップします。

```
> unzip PocketLiff-master.zip
> cd PocketLiff-master
> npm install
> mkdir public\pocket_liff\img\cache
```

ただし、HTTPSである必要がありますので、フロントにプロキシサーバを配置するか、certフォルダにSSL証明書を置く必要があります。

以下の部分を環境に合わせて変更します。

```js:api\controllers\pocket-api\index.js
const LIFF_ID = "【LINEのLIFF ID】";
const LINE_CHANNEL_ID = "【LINEのチャネルID(LINEログイン)】";

const POCKET_CONSUME_KEY = "【PocketのConsumer Key】";
const LINE_CHANNEL_ACCESS_TOKEN = "【LINEのチャネルアクセストークン(長期)】";
const LINE_CHANNEL_SECRET = "【LINEのチャネルシークレット】";
const LINE_RICHMEMU_ID = "【LINEのリッチメニューID】";
```

```js:public\pocket_liff\js\start.js
const LIFF_ID = "【LINEのLIFF ID】";
```

起動は以下のように実行します。

```
> node app.js
```

今一度、LINE Developersコンソールを開き、Webhook URLの検証ボタンを押下すると、成功すればOKです。
ちなみに、以下、主要なフォルダの説明です。

・public\pocket_liff
静的Webページを公開しているフォルダです。今回の場合はLIFFアプリです。
以下のURLでアクセスできます。

　```https://[立ち上げるNode.jsサーバのホスト名:ポート番号]/pocket_liff/```

ただし、LIFFアプリとして登録してあるため、以下でもアクセスできます。

　```https://liff.line.me/【LINEのLIFF ID】```

・api\controllers\pocket-api
WebAPIのエンドポイントが実装されています。

エンドポイントは以下で設定しています。

　```api\controllers\pocket-api\swagger.yaml```

以下の3つのエンドポイントがあることがわかります。

  ・/pocket-line ： LINEボット用
  ・/pocket-signin ： LIFFアプリ用
  ・/pocket-retrieve ： LIFFアプリ用

・data\pocket\users.json
登録したユーザ情報の保持するためのJSONファイルです。最初は空配列です。

# サイトリスト取得までの流れ

以下順番に説明していきます。

・LINEボットお友達登録
・LINEボット応答
・LIFF起動
・LINEログイン
・Pocket承諾
・サイトリスト取得(LIFF)
・サイトリスト取得(LINEボット)

## LINEボットお友達登録

お手持ちのスマホのLINEアプリから、メモった「QRコード(友達登録用)」をスキャンして、LINEボットをお友達登録します。

登録すると、以下のようなメッセージが返ってきます。

```
{Nickname}さん はじめまして！{AccountName}です。 友だち追加ありがとうございます(moon wink) このアカウントでは、最新情報を定期的に配信していきます(loveletter) どうぞお楽しみに(gift)(2 stars)
```

これは、デフォルトの設定のままにしているためで、LINE Developersコンソールの応答メッセージにて変更可能です。

## LINEボット応答

立ち上げたNode.jsサーバにて、LINEアプリからの操作に応答することができます。
今回は、テキスト入力したメッセージと、リッチメニューからのポストバックに対する応答を実装しています。

リッチメニューのヘルプと最新リストをクリックすると、ポストバックが返るようにしています。
一番単純なヘルプをクリックした場合は、データ「menu_help」のポストバックが遅れられます。以下の部分でそれを受信しています。

```js:api/controllers/pocket-api/index.js
app.postback(async (event, client) =>{
	console.log(event);
	switch(event.postback.data){
		case 'menu_help' : {
```

後はそれに対して、以下を実行することで、単純なメッセージを返しています。

```js:api/controllers/pocket-api/index.js
var message = app.createSimpleResponse("フィルタリングしたいキーワードを入力するか、最新リストボタンを押してください。\nまだPocketを登録していない場合は、アプリを起動してください。");
	return client.replyMessage(event.replyToken, message);
```

また、LINEアプリから、通常のテキスト入力した場合には、以下の部分で受信します。

```js:api/controllers/pocket-api/index.js
app.message(async (event, client) =>{
```

それに対する応答処理は後述します。

ユーティリティの詳細は、以下の投稿か、「line-utils.js」をご確認ください。
　[LINEボットを立ち上げるまで。LINEビーコンも。](https://qiita.com/poruruba/items/c71c96ca83c7403c2930)

## LIFF起動

リッチメニューの「アプリ」を選択すると、LIFF URLが送られてLIFFアプリが起動するようにしてあります。

最初は以下のような画面が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/4b3d4184-03b7-5636-6179-95fb85802f1d.png)

## LINEログイン

最初に起動すると、LINEログインを促されます。

※LINEアプリからLIFF起動した場合には、自動的にLINEログインが行われるため、以降のLINEログイン処理はありません。

(参考）
https://developers.line.biz/ja/docs/liff/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/2c2ae470-dd9a-8b05-38e5-f060c266cf73.png)

OKボタンを押下すると、LINEログイン画面に遷移します。
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9a7b97f2-a5e4-4593-98a2-c7ec76bfca56.png)

ログインが完了すると、以下のようにLINEのユーザのディスプレイ名が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8cf75eb3-b54f-c02b-848d-02020e28d053.png)

※LINEアプリからLIFFを起動した場合は、「ログアウト」ボタンはありません。LINEログインが自動的に行われログアウトしないためです。

ロジックの部分を抜粋します。

まずは、必ずまっさきにliff.initを呼び出す必要があります。Promiseですので、非同期で処理を待ちます。

```js:public/pocket_liff/js/start.js
        liff.init({ liffId: LIFF_ID })
        .then( () => {
            this.initialize();
        })
        .catch( (err) => {
            console.error(err);
            alert(err);
        })
        .finally(() => {
            history.replaceState('', '', location.pathname);
        });
```

終わった後に、LINEログイン状態かどうか確認し、ログインしていなければアラートダイアログを表示したのちログインします。

```js:public/pocket_liff/js/start.js
        initialize: async function(){
            try{
                this.line_inClient = liff.isInClient();
                this.line_loggedin = liff.isLoggedIn();
                console.log("isLoggedIn: " + this.line_loggedin);
                if( liff.isLoggedIn() ){
                    ・・・・
                }else{
                    alert('最初にLINEログインが必要です。');
                    liff.login();
                }
            }catch(err){
                console.error(err);
                alert(err);
            }
        }
```

ログインは、LINE提供のWebページに遷移し、ログイン完了後、またこのLIFFアプリに戻ってきます。ログイン後に戻ってくると、今度はログイン状態がYesとなっています。

ちなみに、LINEログインページから戻ってきたときには、liff.initの中で、QueryStringを参照していますので、liff.initの前にQueryStringをいじらないようにしましょう。


## Pocket承諾

さあいよいよ、Pocketと連携します。
Pocketサーバと連携して、アクセストークンを取得するところまでが最初のゴールです。
取得したアクセストークンは、LINEのuserIdに紐づけてNode.jsサーバ側で管理するので、LINEログインされていることが必須としています。

Pocketに登録した情報をNode.jsサーバが扱えるようにするには、ユーザによる承諾行為が必要となります。
そして、その承諾行為をする前に、まずはPocketサーバに対してcodeを一時的に払い出してもらいます。

以下の部分です。

```js:api/controllers/pocket-api/index.js
			var result = await do_post_pocket("https://getpocket.com/v3/oauth/request", {
				consumer_key: POCKET_CONSUME_KEY,
				redirect_uri: POCKET_REDIRECT_URL
			});
			console.log(result);

			user.pocket_code = result.code;
```

（参考）Pocket Authentication API Documentation
https://getpocket.com/developer/docs/authentication

その後、ユーザに対して承諾をしてもらうためのPocketサーバが提供するページに遷移してもらいます。
以下のようにして、遷移してほしいURLを返しています。

```js:api/controllers/pocket-api/index.js
			return new Response( {
				signin_url: "https://getpocket.com/auth/authorize?request_token=" + result.code + "&redirect_uri=" + POCKET_REDIRECT_URL,
			});
```

ちなみに、「POCKET_REDIRECT_URL」は以下のように定義されています。

　```const POCKET_REDIRECT_URL = "https://liff.line.me/" + LIFF_ID + "/?cmd=PocketSignin";```

Pocketサーバで承諾行為をしたのち、戻ってくるページのURLを指定しています。戻り先は同じくLIFFアプリではあるのですが、承諾した後の戻りであることがわかるようにcmd=PocketSigninというQueryStringを追加しています。

LIFFアプリで、指定されたURLに遷移すると、以下のPocketサーバ提供ページが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8b6b259f-bf30-9aac-b11a-912a6352de94.png)

許可または拒否すると、LIFFアプリに戻ってきます。

LIFFアプリでは、QueryStringを参照して、Pocketサーバからの戻りであることを判別し、以下を実行します。

```js:public/pocket_liff/js/start.js
                    if( searchs.cmd == 'PocketSignin' ){
                        var result = await do_post(base_url + "/pocket-signin", {
                            id_token: liff.getIDToken(),
                            cmd: 'PocketSignin'
                        });
                        console.log(result);
                        localStorage.setItem("pocket_username", result.pocket_username);
                        this.pocket_username = result.pocket_username;
                        this.line_displayName = result.line_displayName;
                        alert('Pocketサインインし、登録が完了しました。');
                    }
```

Node.jsサーバ側では、以下のようにして承諾されたか確認し、承諾されると、Pocketのアクセストークンが取得されます。もしユーザが承諾していなかった場合にはエラーが返ります。

```js:api/controllers/pocket-api/index.js
		if( body.cmd == 'PocketSignin'){
			if( !user.pocket_code )
				throw new Error("invalid status");

			var result = await do_post_pocket("https://getpocket.com/v3/oauth/authorize", {
				consumer_key: POCKET_CONSUME_KEY,
				code: user.pocket_code
			});
			console.log(result);

			user.pocket_code = null;
			user.pocket_access_token = result.access_token;
			user.pocket_username = result.username;
```

## サイトリスト取得(LIFF)

LIFFアプリから、サイトリストを取得するには、Pocketのアクセストークンが必要です。そのトークンはNode.jsサーバが保持していますので、サーバに依頼します。

```js:public/pocket_liff/js/start.js
            var result = await do_post(base_url + "/pocket-retrieve", {
                id_token: liff.getIDToken(),
                num_of_items: POCKET_SEARCH_COUNT,
                offset: 0,
                search: this.searching_word
            });
```

依頼した人が正しいかを確認するために、LINEのIDトークンも渡しています。実はPocket承諾の際も、サーバ側でLINEのIDトークンの検証を行っています。

もし、IDトークンが正しければ、以下のWebAPI呼び出しは成功します。

```js:api/controllers/pocket-api/index.js
	var result = await do_post_urlencoded("https://api.line.me/oauth2/v2.1/verify", { id_token: body.id_token, client_id: LINE_CHANNEL_ID });
	console.log(result);
	var userId = result.sub;
	var displayName = result.name;
```

これにより、アクセスしてきたユーザのLINEのuserIdがわかります。

あとは、以下のようにPocketのアクセストークンを使ってPocketサーバに対してWebAPI呼び出しをすると、サイトリストを取得することができます。

```js:api/controllers/pocket-api/index.js
		var params = {
			consumer_key: POCKET_CONSUME_KEY,
			access_token: user.pocket_access_token,
			sort: "newest",
			count: body.num_of_items
		};
		if( body.offset )
			params.offset = body.offset;
		if( body.search )
			params.search = body.search;
		var result = await do_post_pocket("https://getpocket.com/v3/get", params );
//		console.log(result);
```

ただ、取得されたリストが少々扱いづらいので、以下のように変換したりソートしたりしています。また、リストによっては一部のデータが存在しない場合があるため、代替文字を設定するようにしています。

```js:api/controllers/pocket-api/index.js
		let list = Object.keys(result.list).reduce((list, item) => {
			if( !result.list[item].resolved_title ) result.list[item].resolved_title = result.list[item].given_title || "no title";
			if( !result.list[item].excerpt ) result.list[item].excerpt = "no description";
			if( !result.list[item].resolved_url ) result.list[item].resolved_url = result.list[item].given_url;
			list.push(result.list[item]);
			return list;
		}, []);
		list.sort((first, second) =>{
				let first_time = parseInt(first.time_added);
				let second_time = parseInt(second.time_added);
				if( first_time > second_time )
						return -1;
				else if( first_time == second_time )
						return 0;
				else
						return 1;
		});
```

## サイトリスト取得(LINEボット)

サイトリスト取得(LIFF)とほぼ同じですが、差分について説明します。

LINEボットでのサイトリストの表示には、カルーセルを利用します。カルーセルのひな型は、Flex Message Simulatorで作成しました。

https://developers.line.biz/flex-simulator/

詳細は、以下を参照してください。

　[LINEボットを立ち上げるまで。LINEビーコンも。](https://qiita.com/poruruba/items/c71c96ca83c7403c2930)

カルーセルを形成するためのデータを関数「createCarouselList」で作成しています。
こんな形式のJSONを作ればよいようにしています。

```js:api/controllers/pocket-api/index.js
		var obj = {
			title: item.resolved_title,
			desc: item.excerpt,
			image_url: image_url,
			action_text: "ブラウザ起動",
			action: {
				type: "uri",
				uri: item.resolved_url 
			}
		};
```

で、注意がありまして、カルーセルに表示する画像のサイズには上限があるため、リサイズが必要です。

https://developers.line.biz/ja/reference/messaging-api/#f-image

そこで、以下のnpmモジュールを使って既定のサイズのJPEGファイルにリサイズします。

https://github.com/lovell/sharp

```js:api/controllers/pocket-api/index.js
const sharp = require('sharp');

async function resize_image(url, id){
	try{
		var fname = CACHE_FNAME_BASE + id + ".jpg";
		if( !await fileExists(fname) ){
			var buffer = await do_get_binary(url);
			await sharp(Buffer.from(buffer))
				.resize({
					width: CACHE_IMAGE_WIDTH,
					height: CACHE_IMAGE_WIDTH,
					fit: "inside"
				})
				.jpeg()
				.toFile(fname);
		}
		return PUBLIC_BASE_URL + "img/cache/" + id + ".jpg";
	}catch(error){
//		console.log(error);
		return PUBLIC_BASE_URL + "img/default_image.png";
	}
}
```

作成したJPEGファイルは、publicフォルダに保存し公開します。

# 終わりに

一通り、説明したつもりです。
不明点あればお知らせください。

以上