38
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-07-19

背景

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

つくったもの

Firebase Hostingにデプロイしたサイトを開き、プロジェクターでスクリーンに投影する。
参列者が撮影した写真を結婚式用LINE公式アカウントに送ると、スクリーンに写真が表示される。
10秒に1度、新しい写真を表示する。
新しい写真がなければ、写真投稿を促すデフォルト画像を表示する。
スクリーンショット 2021-07-20 17.58.34.png

どうだった?

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

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

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

アーキテクチャ

ソースサンプル

Cloud Funtions

// 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)

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の公開

参考にした記事

追記

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
38
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?