結婚式のフォトギャラリーをLINE bot + Firebaseで作った話

## 背景
ご時世的にも余興なしで結婚式と披露宴をやろうと思っていましたが、妻から「招待状をLINE bot使ってやる記事を読んだ。うちもこれにしよう」と提案をもらったので、ついでにLINE botを使った余興代わりのものを開発しました。

## つくったもの
Firebase Hostingにデプロイしたサイトを開き、プロジェクターでスクリーンに投影する。
参列者が撮影した写真を結婚式用LINE公式アカウントに送ると、スクリーンに写真が表示される。
10秒に1度、新しい写真を表示する。
新しい写真がなければ、写真投稿を促すデフォルト画像を表示する。
![スクリーンショット 2021-07-20 17.58.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272364/a3d35a53-6627-574d-a80b-23abb3e1393d.png)


## どうだった？
スクリーンに30分写すとして、180枚も写真が必要…。
そんなに送ってもらえるかなぁ…と心配していましたが、一人で20枚くらい送ってくださった方もいて、大盛況でしたｗ

ただし、これにはデメリットもあります。
挙式中の新郎新婦の写真ばかり送られてきて、めちゃめちゃ恥ずかしいことですｗ

誓いのキスを入れている人は気をつけてくださいｗ
絶対そのシーンの写真ばかりになりますよ！！
（うちはキスしなかったので辛うじて致命傷で済んだ）

## アーキテクチャ
```plantuml
@startuml wedding
actor "みんな" as User
participant "LINE bot" as LINE
box "Firebase" #OrangeRed/Yellow
participant "Cloud Functions" as FF
participant "Cloud Storage" as FS
participant "Firebase Hosting" as FH
end box
participant "スクリーン" as PC

== 事前準備 ==
User --> LINE: 友だち登録
== 結婚式当日 == 
User --> LINE: 写真送信
LINE --> FF: Webhook
FF --> FS: 写真保存
...
PC --> FH: 写真表示サイトリクエスト
PC <-- FH: 写真表示サイト（React.js）
loop "10秒ごと繰り返し"
PC --> FS: 写真一覧取得
activate PC
PC <-- FS: 写真一覧
PC --> FS: 新規写真URL取得
PC <-- FS: 写真URL
PC --> PC: 写真Indexをインクリメント
FS <-- PC: 次の写真を取得
FS --> PC: 次の写真を表示
deactivate PC
end
@enduml
```

## ソースサンプル
### Cloud Funtions
```typescript
// FirebaseAdminSDKを利用する
admin.initializeApp({
  credential: admin.credential.applicationDefault(),
  storageBucket: functions.config().wedding.bucket,
});

const bucket = admin.storage().bucket();
// LINE　Botに関するシークレットやトークンはCloud Functionsの環境変数に設定
const CHANNEL_SECRET = functions.config().wedding.channelsecret;
const ACCESS_TOKEN = functions.config().wedding.channelaccesstoken;

// TODO: streamが使えるとスマートにいけそうだけど、時間切れで断念
const saveImage = async (id: string) => {
  // 画像を取得
  const response = await axios({
    method: "get",
    responseType: "arraybuffer",
    url: `https://api-data.line.me/v2/bot/message/${id}/content`,
    headers: {
      "Authorization": `Bearer ${ACCESS_TOKEN}`,
      "Content-Type": "image/jpeg",
    },
  });
  const filePath = `/tmp/${id}.jpg`;

  // 画像を一時保存
  await promises.writeFile(filePath, response.data);

  // Cloud Storage for Firebaseに保存
  await bucket.upload(filePath, {
    destination: `wedding/${id}.jpg`,
    metadata: {
      metadata: {
        firebaseStorageDownloadTokens: uuidv4(),
      },
    },
  });

  // 一時保存した画像を削除する
  await promises.unlink(filePath);
};

// LINE botのwebhookを受ける関数
export const line = functions
    .region("asia-northeast1")
    .runWith({timeoutSeconds: 540, memory: "4GB"})
    .https
    .onRequest(async (req, res) => {
      // LINE公式アカウントからのHTTP Requestかどうか検証する
      const signature = crypto.createHmac("SHA256", CHANNEL_SECRET)
          .update(req.rawBody).digest("base64");
      if (req.get("x-line-signature") !== signature) res.end();

      // 疎通確認のリクエスト対策
      if (req.body.events.length === 0 || !("type" in req.body.events[0])) {
        res.status(200).send("ng");
      } else {
        const event = req.body.events[0];
        // 送られたメッセージが画像タイプであるか検証する
        if (event.type === "message" && event.message.type === "image") {
          // messageIdから画像を取得する
          await saveImage(event.message.id);
        }

        // 正常終了
        res.status(200).send("ok");
      }
    });
```

### Firebase Hosting(React.js)
```javascript
const reducer = (_, action) => ({
  imageNameList: action.imageNameList,
  readyImageUrlList: action.readyImageUrlList,
});

const initialState = {
  imageNameList: [],
  readyImageUrlList: [],
};

const LinePhoto = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    // 10秒に1度実行されるロジック本体
    const timeoutId = setTimeout(async () => {
      // Cloud Storageから全画像を取得
      const res = await firebase.storage().ref().child('wedding').listAll();
      // 取得済みの画像を除く
      const targets = res.items.filter(item => !state.imageNameList.includes(item.name));
      const newImages = await Promise.all(targets.map(async (target) => {
        return await target.getDownloadURL();
      }));

      // stateを更新
      const list = [...state.readyImageUrlList].concat(newImages);
      if (state.readyImageUrlList.length > 0) list.shift();

      dispatch({
        imageNameList: res.items.map(item => item.name),
        readyImageUrlList: list,
      });
    }, 10000);
    return () => clearTimeout(timeoutId);
  }, [state]);

  // 画素数が小さい画像は無理やり引き伸ばされるスタイル
  return (
    <div className="App">
      {state.readyImageUrlList.length === 0 ? (
        <img src={/*LINE公式アカウントに写真を促すデフォルト画像*/} className="App-image" alt="LINE公式アカウントに写真を送ってね！" />
      ) : (
        <img src={state.readyImageUrlList[0]} className="App-image" alt="wedding" />
      )}
    </div>
  );
};
```


## ハマったポイント
* LINE公式アカウントの応答モードを「Bot」にしていなかったせいで、Messaging APIのWebhookを設定しても、全くRequestがとんでこなかった
* しかも設定するだけじゃなく、「Verify」を押下してsuccessしないとだめっぽい
* Cloud Functionsを非公開のままウンウン唸ってた


## TODO
* 送られてきた画像をstreamをつかって横流しする方法ができればスマート
* Cloud Storageにアクセス制限を入れていない
    * やるなら直接Cloud StorageのURLを渡すのではなく、署名付きURLを返すCloud Functionsを新たに作る
* 未デザイン
* No自動テスト
* NoGitHubAcitons
* GitHubの公開

## 参考にした記事
https://qiita.com/nishikori223/items/fda9db5b3da6919e9db5

## 追記
plantumlのソース自体が欲しいとお声がけ頂いたので

```
@startuml wedding
actor "みんな" as User
participant "LINE bot" as LINE
box "Firebase" #OrangeRed/Yellow
participant "Cloud Functions" as FF
participant "Cloud Storage" as FS
participant "Firebase Hosting" as FH
end box
participant "スクリーン" as PC

== 事前準備 ==
User --> LINE: 友だち登録
== 結婚式当日 == 
User --> LINE: 写真送信
LINE --> FF: Webhook
FF --> FS: 写真保存
...
PC --> FH: 写真表示サイト
loop "10秒ごと繰り返し"
FH --> FS: 写真一覧取得
activate FH
FH <-- FS: 写真一覧
FH --> FS: 新規写真URL取得
FH <-- FS: 写真URL
FH --> FH: 写真Indexをインクリメント
PC <-- FH: 次の写真を表示
deactivate FH
end
@enduml
```