LINE BotとWebサイトを組み合わせたフォトアルバムを作りました

この記事は、株式会社エイチームフィナジーの[Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ateam-finergy) 10 日目の記事になります。
20 卒エンジニアの@tommy1038 が担当いたします。

先日友人の結婚式での余興として制作した、LINE Bot と Web サイトを組み合わせた、式の参列者で作る、オリジナルフォトアルバムのお話しをします。

また、こちらは先日公開した、こちらの技術的補足記事になります。
[結婚式の余興を担当してきました。\(その 2\)｜TomiyamaノR🔥｜note](https://note.com/noir_tomiyama/n/naa38b1331b0c)

結婚式の余興として、LINE Bot を用いている例は数多くあり、その方々が記事を執筆してくれたおかげで僕もサービスを作成することができました。この場をお借りして、お礼申し上げます。

## はじめに

制作した Web サイトはこちらになります。こちらのサイトが LINE Bot と連携しており、これらを紹介するフライヤーも用意しました。
参列してくれた方が、いろんな写真をたくさんアップロードしてくれました。

![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/b35e02c1-7b3b-8c1a-df10-e95006ed77f0.png)

### サービスの流れ

結婚式当日、参列者の方に紹介するためスライドを用意し、フライヤーの配布も行いました。フライヤーの制作秘話については、翌日のアドベントカレンダー 11 日目担当、弊社デザイナーの@tomoto_yu が紹介してくれます。またサイトのデザインなども彼女が担当してくれました。サービスの大まかな流れは以下の通りです。

- LINE Bot に、結婚式で撮影した写真を送信してもらう。
- アップロードとともに、笑顔度合いの判定値を返す。
- 特設サイトにアクセスすると、いろんな人が LINE Bot にあげてくれた写真を見ることができる。

当日投影したスライドは、以下に紹介します。サンプルで複数人の画像を投げていますが、わかりやすさのために、サイト上では追加で 3 枚ほどいれて説明しています。

![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/4e1e4f38-14be-7a33-4689-69a67c9e7f72.png)

### サービスの構成

サービスの構成図は、以下のようになります。記事の中で順を追って紹介していきます。

![infra.001.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/0fe3d89f-d1ea-f1e6-93fb-22c304d71d06.jpeg)

## LINE Bot で画像を受け取って保存する

今回、LINE Bot を介して送信された文字列や画像データを処理するために、AWS Lambda を用いました。

> AWS Lambda を使用すると、データの変更、システムステータスの遷移、またはユーザーによるアクションをトリガーとし、その応答としてコードを実行できます。
> (https://aws.amazon.com/jp/lambda/ から抜粋)

### LINE Bot の導入

画像データを処理する前に、LINE Bot に対して送られた文字列をおうむ返しする仕組みを作成して、雰囲気をつかんでいきましょう。こちらの記事が参考になります。
[AWS Lambda を使って LINEBot を作ってみよう！ \- Qiita](https://qiita.com/shinbunbun_/items/ae09364504002d0c25f1)

これにより、LINE Bot に「あいうえお」と送信すれば、「あいうえお」と返ってくることが確認できました。

### 画像を S3 に保存する

上記で、文字列に対しての処理ができるようになったので、次に送られてきた画像を Amazon Simple Storage Service (Amazon S3)に保存させます。

こんな感じの実装になると思います。一部抜粋して紹介します。

```node.js
const messageFunc = (e) => {
  if (e.message.type == "image") {
    // 画像をS3に保存する
    getImage(e.source.userId, e.message.id)

    //ユーザーに返信するメッセージを作成
    let message;
    message = {
        type: "text",
        text: "画像をアップロードしています・・・"
    };
    client.replyMessage(e.replyToken, message);
  }
};

const getImage = function (userId, messageId) {
  // Request Headers
  var sendOptions = {
    host: 'api.line.me',
    path: '/v2/bot/message/' + messageId + '/content',
    headers: {
      "Content-type": "application/json; charset=UTF-8",
      "Authorization": " Bearer " + process.env.ACCESSTOKEN
    },
    method: 'GET'
  };

  // APIリクエスト
  var req = https.request(sendOptions, function (res) {
    var data = [];
    res.on('data', function (chunk) {
      // image data dividing it in to multiple request
      data.push(new Buffer.from(chunk));
    }).on('error', function (err) {
      console.log(err);
    }).on('end', function () {
      saveImage(userId, data); // S3に保存する関数
      rekognitionImage(userId, data); // 後半で紹介する、Rekognitionによって判定をさせる関数
    });
  });
  req.end();
};

const saveImage = function (userId, data) {
  var params = {
    Bucket: 'my-bucket-sample-1038', // ←バケット名
    Key: `images/${timestamp}.jpg`, // ←バケットに保存するファイル名
    Body: Buffer.concat(data)
  };

  s3.putObject(params, function (err, data) {
    // 画像保存後の処理
    console.log("saved image")
    if (err) {
      console.log(err)
    }
    // ユーザーに返信するメッセージを作成
    let message;
    message = {
      type: "text",
      text: "写真のアップロードが完了しました。"
    };
    client.pushMessage(userId, message);
  });
};
```

ここで、注意点としては、replyMessage メソッドは 1 度しか使用できません。複数回ユーザーにメッセージを送信したい場合には、pushMessage を使用するようにしてください。

これにより、LINE Bot に送信した画像を S3 に保存することができました。また、現在時刻を取得し、画像の保存先を`images/20201210123456.jpg`のようなファイル名にしています。

#### 参考

- [LINE Bot に投稿した画像を Lambda で受けて S3 に保存する \- Qiita](https://qiita.com/vedokoy/items/165eaa90015e0181475a)
- [new Client\(config\) \| line\-bot\-sdk\-nodejs](https://line.github.io/line-bot-sdk-nodejs/api-reference/client.html#message)
- [Messaging API リファレンス \| LINE Developers](https://developers.line.biz/ja/reference/messaging-api/#send-reply-message)

## LINE Bot で笑顔の判定値を返す

今回のサービスでは、LINE Bot に送信された画像に対して、笑顔の判定値を返しています。その判定値を算出する際に利用したのが、Amazon Rekognition になります。

> Amazon Rekognition では、イメージ分析とビデオ分析をアプリケーションに簡単に追加することができます。Amazon Rekognition API にイメージやビデオを指定するだけで、このサービスによってモノ、人物、テキスト、シーン、アクティビティを識別できます。(https://docs.aws.amazon.com/ja_jp/rekognition/latest/dg/what-is.html より抜粋)

今回は、Amazon Rekognition の中の、**感情分析**の機能を利用しました。こちら([イメージ内の顔の検出 \- Amazon Rekognition](https://docs.aws.amazon.com/ja_jp/rekognition/latest/dg/faces-detect-images.html))に示されているように、DetectFaces を呼び出すことによって、表情の判定、および判定値を算出することができます。

こちらの記事を参考にさせていただきました。
[Amazon Rekognition × LINE Bot を試してみた \- Qiita](https://qiita.com/_mogaming/items/6b19700b1f6da7633cc4)

実際には、以下のように画像を判定させました。

```node.js
const rekognitionImage = function (userId, data) {
  const rekognition = new AWS.Rekognition();
  const buf = Buffer.concat(data)
  const params = {
    Image: {
      Bytes: buf
    }
  };

  // 物体、シーン分析
  rekognition.detectLabels(params, function (err, data) {
    if (err) {
      console.log(err, err.stack);
    } else {
      console.log(JSON.stringify(data));
      var mes = "";
      data.Labels.forEach(function (label) {
        mes += label.Name + ": " + Math.round(label.Confidence) + "\%\n";
        // 人を検出した場合のみ顔分析もする
        const labelName = label.Name
        if (labelName.includes("Person")) {
          params["Attributes"] = ["ALL"];
          // 顔分析
          rekognition.detectFaces(params, function (err, data) {
            if (err) {
              console.log(err, err.stack);
            } else {
              console.log(JSON.stringify(data));
              var sumSmile = 0;
              var smileNumber = 0;
              data.FaceDetails.forEach(function (detail, index, details) {
                const isSmile = detail.Smile.Value;
                if (isSmile) {
                  sumSmile += detail.Smile.Value;
                  smileNumber++;
                } else {
                  sumSmile -= 1;
                }

                let message = "";
                if (index === details.length - 1) {
                  console.log("last");
                  if (smileNumber == 0) {
                    message = "得点をつけるのが難しい写真だよ。"
                  } else {
                    var aveSmile = sumSmile / smileNumber;
                    if (aveSmile <= 60) {
                      aveSmile = 65.3322; // 最低点を設定
                    } else if(aveSmile <= 90){
                      aveSmile += smileNumber;
                    }
                    message = `今回のスマイルポイントは、${aveSmile.toFixed(2)}点だよ！`
                  }
                  console.log(aveSmile);

                  let resultMessage = {
                    type: "text",
                    text: message
                  };
                  client.pushMessage(userId, resultMessage);
                }
              });
            }
          });
        }
      });
    }
  });
}
```

スマイルポイントに関しては、ユーザーに示す際の笑顔度合いの点数になります。
detectFaces で数値を取得しますが、`Smile`のラベルを取得しても、`Value`と`Confidence`のプロパティーがあります。`Value`が`false`の場合は欲しい値ではないので捨てて、`true`のときのみ`Confidence`から欲しい値を算出しました。複数人写っている場合には最後に気持ちばかし加点をしたり、不快に思う方がでないように最低点を設定したり、少しだけ工夫をしています。正直、この調整には納得いっておりません。大人数の加点や、笑顔でない人や小さく映り込んでしまった人の対応などもう少し配慮する余地がありました。

## S3 に保存されている画像をサイト上に反映させる

現在、`images/${timestamp}.jpg`のように S3 に画像が保存されているので、これらの写真をサイトに表示させます。業務で Rails を触っているので Rails を選択しましたが、多少オーバースペックだったかもしれません。
ちなみに、サイトがレスポンシブ対応し、縦と横の画像が良い感じに組み合わせられるのは、弊社デザイナーの@ryoma-aoiao の協力があったからです。彼には、このサイトの基になるように、[ここ](https://noirtomiyama.github.io/white-bouquet-basic/)まで事前に作成していただいておりました。本当に頭があがりません。

### Rails から S3 にアクセスする

S3 にアクセスするために、`aws-sdk-s3`という gem を入れます。また、環境変数なども扱うため、`dotenv-rails`を導入しています。

- [aws/aws\-sdk\-ruby: The official AWS SDK for Ruby\.](https://github.com/aws/aws-sdk-ruby)
- [bkeepers/dotenv: A Ruby gem to load environment variables from \`\.env\`\.](https://github.com/bkeepers/dotenv)

S3 に保存されている画像を表示させるだけでよかったので、Controller と View はこのような形にしました。

```top_controller.rb
class TopController < ApplicationController
  def index
    resource = Aws::S3::Resource.new(
      region: "ap-northeast-1",
      credentials: Aws::Credentials.new(
        ENV['AWS_ACCESS_KEY_ID'],
        ENV['AWS_SECRET_ACCESS_KEY']
      )
    )
    bucket = resource.bucket('my-bucket-sample-1038')

    @images = []
    bucket.objects.each do |object|
      if((object.key).include?('images/')) then
        puts "Name:  #{object.key}"
        puts "URL:   #{object.presigned_url(:get)}"
        @images.push(object.presigned_url(:get))
      end
    end
  end
end
```

```index.html.erb(一部抜粋)
 <% @images.each do |image| %>
  <li class="item">
    <img src=<%= image %> >
  </li>
<% end %>
```

これにより、S3 の画像を Rails を介して、サイトに表示させることができました。

### 得点と投稿者名を表示する

上記の手順で、一通り作成したいサービスを作りきることができました。しかし現状では、LINE Bot に送信した参列者しかその得点を把握することできません。実は、今回のサービスは、余興であると同時に、新郎新婦のプレゼント企画としても繋がっていました。そのプレゼント企画は以下の判断基準にて、プレゼントを渡す、というものでした。

- 新郎新婦を撮影して、笑顔の得点が 1 番高かった人
- だれかに撮影されて、笑顔の得点が 1 番高かった人
- 新郎新婦の判断で、素敵な写真を撮影してくれた人

であるので、新郎新婦が、写真の投稿者と得点の一覧を見れるサイトを作成する必要がありました。なので、bucket の`images`ディレクトリに画像を置いていましたが、新たに`results`ディレクトリを作成し、そちらに ①`images`ディレクトリにある画像パス、② 投稿者名、③ 得点の 3 つを一まとめにした json ファイルを保存することにしました。
以下がイメージ図です。

![スクリーンショット 2020-12-06 21.41.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/968fbe98-d029-db3e-5c72-6163cf190b79.png)

#### 投稿者名の取得

投稿者名に関しては、以下の処理で取得しました。[Messaging API リファレンス \| LINE Developers](https://developers.line.biz/ja/reference/messaging-api/#get-profile)

```node.js
var username = "UnknowName";

// ユーザー名の取得
const getUser = function (userId) {
  // Request Headers
  var sendOptions = {
    host: 'api.line.me',
    path: '/v2/bot/profile/' + userId,
    headers: {
      "Content-type": "application/json; charset=UTF-8",
      "Authorization": " Bearer " + process.env.ACCESSTOKEN
    },
    method: 'GET'
  };

  // APIリクエスト
  var req = https.request(sendOptions, function (res) {
    var data = "";
    res.on('data', function (chunk) {
      data += chunk;
    }).on('error', function (err) {
      console.log(err);
    }).on('end', function () {
      const obj = JSON.parse(data);
      username = obj.displayName;
      console.log(username);
    });
  });
  req.end();
}
```

#### 投稿者、画像パス、得点を S3 に保存する

投稿者の取得ができたので、S3 にそれぞれを含んだ json ファイルにし、保存したいと思います。以下のような流れで保存できます。`username`には、上で取得した投稿者名、`timestamp`には、現在時刻が入っている想定です。

```node.js
const imagePath = `images/${timestamp}.jpg`;
// ここで名前とパスと得点を保存する
const paramsJson = {
  Bucket: 'my-bucket-sample-1038', // ←バケット名
  Key: `result/${timestamp}.json`, // ←バケットに保存するファイル名
  Body: JSON.stringify({ image: imagePath, name: userName, score: aveSmile })
};

s3.putObject(paramsJson, function (err, data) {
  // 画像保存後の処理
   console.log("saved file")
   if (err) {
    console.log(err)
   }
});
```

#### S3 に保存された json ファイルを元に View に表示する

２つの画面が必要になったので、以下のように役割を分けました。

- ルート(/): 参列者があげてくれた写真を一覧で見れる画面
- ランキング(/rankings): 写真に加えて、投稿者名と得点が見れる画面

残りは、/rankings の作成です。json の中身を取得し、View に反映していきます。Controller と View はこのような形になります。なお、indexアクションは省略しております。

```top_controller.rb
class TopController < ApplicationController
  def show
    resource = Aws::S3::Resource.new(
      region: "ap-northeast-1",
      credentials: Aws::Credentials.new(
        ENV['AWS_ACCESS_KEY_ID'],
        ENV['AWS_SECRET_ACCESS_KEY']
      )
    )
    bucket_name = 'my-bucket-sample-1038'
    bucket = resource.bucket('my-bucket-sample-1038')

    client = Aws::S3::Client.new(
      region: "ap-northeast-1",
      credentials: Aws::Credentials.new(
        ENV['AWS_ACCESS_KEY_ID'],
        ENV['AWS_SECRET_ACCESS_KEY']
      )
    )

    @object_list = []
    bucket.objects.each do |object|
      if((object.key).include?('result/')) then
        data = JSON.parse(client.get_object(bucket: bucket_name, key: object.key).body.read)
        image = bucket.object(data['image'])
        image_url = image.presigned_url(:get)
        name = data['name']
        score = data['score']
        item = {
          image: image_url,
          name: name,
          score: score
        }
        @object_list.push(item)
      end
    end
  end
end
```

```show.html.erb(一部抜粋)
<% @object_list.each do |object| %>
  <li class="item">
    <img src=<%= object[:image] %> >
    <p>
      <i class="fas fa-camera-retro"></i> <%= object[:name] %> さん<br>
      <i class="far fa-smile-wink"></i> <%= object[:score]&.round(4) %> 点
    </p>
  </li>
<% end %>
```

S3 の json ファイルの中身にアクセスするために、`Aws::S3::Client`を使用する必要がありました。上のようなコードで実装できましたが、もう少しうまく書ける気がしております。。。

## (番外編) 工夫したこととハマったこと

サービスを制作中に、工夫したこととハマったことを一部紹介します。

### [lokesh/lightbox2: THE original Lightbox script \(v2\)\.](https://github.com/lokesh/lightbox2)

画像をクリックしたときに、写真が拡大されるようにしました。(jQuery です、、、、)

### heroku でのデプロイ時の環境変数の扱い

heroku でデプロイを行いましたが、dot-env を用いていたため、そのままデプロイすると、500 エラーとなってしまいました。[【Node\.js】dotenv を使用して環境変数を設定&設定した環境変数を Heroku にも適用する方法 \- Qiita](https://qiita.com/yajima_tohshu/items/e35d0e16249b67518560) のように、heroku に環境変数を設定して、解決しました。

### AWS Lambda が S3 にアクセスできない

Lambda で s3 バケットに画像を保存させようとした際に、`AccessDenied`となる事象です。こちらに関して、バケットの権限を public にしたりしていたんですが、解決できず困っていましたが、以下のリンクのように、Lambda に対して、IAM のロールから S3 へのアクセス権限を付与してあげると解決しました。

- [Lambda で S3 からファイルを取得しようとしたら botocore\.exceptions\.ClientError: An error occurred \(AccessDenied\) when calling the GetObject operation: Access Denied となった時の対応方法 \- Qiita](https://qiita.com/ponsuke0531/items/c5b0b9fd4f985a311ce9)

- [【AWS Lambda の基本コードその 9】S3 のバケットリスト取得\(Python\) – ナレコム AWS レシピ](https://recipe.kc-cloud.jp/archives/10529)

## さいごに

以上より、

- LINE Bot に、結婚式で撮影した写真を送信してもらう。
- アップロードとともに、笑顔度合いの判定値を返す。
- 特設サイトにアクセスすると、いろんな人が LINE Bot にあげてくれた写真を見ることができる。

の 3 つの機能を備えたサイトを制作することができました。
数多くのはまるポイントがありましたが、同じような機能を作ろうとしている方の助けになれば幸いです。
参考までに、Lambda のコードを[こちら](https://gist.github.com/NoirTomiyama/093edbc5ccd021a862fe70bd530011ec)に置いておきます。

明日は、今回の企画のデザインをしてくれた、@tomoto_yuの記事になります！

## おまけ
結婚式場で最高得点を叩き出した、僕の友人を紹介します。(表情を正確に伝えるために、顔出ししておりますが、本人の許可をしっかりとっております。)
複数人であると平均をとってしまい、点数が落ちがちになってしまいますので、ピン写の方が点数が上がりやすい傾向にあります。とはいえ、ピン写でも99点代の後半まで出せたのはあまりありませんでした。
彼が、いろんな人の結婚式で、素敵な笑顔を今後も出してくれることを期待しています。

![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153992/fe690985-5d98-53d1-e653-3592a84d22c7.png)