【個人開発】Next.jsでLINE BOTアプリ作ってみた（改良版）

# 目次
[1. はじめに](#はじめに)
[2. アプリ概要](#アプリ概要)
[3. 構成図](#構成図)
[4. 使用技術](#使用技術)
[5. 解説](#解説)
[6. 工夫した点](#工夫した点)
[7. 終わりに](#終わりに)

# はじめに
はじめまして。前に請求書を送信できる簡単なアプリを作成したのですが、そのアプリに機能を付け加えたり、デザインを修正したのでもう一度記事を書いて見ることにしました。

前回の記事

https://qiita.com/yuchi1128/items/012618800a17f7705388

# アプリ概要
LIFFのアプリ内で請求書を作成し、その請求書をLINEの友達に送信できます。
### 簡易デモ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/55ed8004-adb8-45f2-e1eb-128e2143cbdd.gif" width="270">

### QRコード
友達追加お願いします！

<img src="https://github.com/user-attachments/assets/61e35050-e1da-474e-95fe-f18d63cee20e" width="200px" alt="請求書発行BOT">

### 機能
- LIFFのアプリ内で請求書を作成できる
- その請求書をLINEの友達に送信できる
- 自分が送った請求書の履歴を見ることができる

### 作成動機
- 家族や友達とお金の貸し借りをよくする
- お金を返してと言いづらいことを解消したい
- 身近なアプリであるLINEでNext.jsを使用したものを作りたかった
- デプロイまでしたかった

### Githubリンク
https://github.com/yuchi1128/nextjs-line-invoice-bot


# 使用技術

**フロントエンド バックエンド**
- TypeScript (Next.js)

**UI**
- Tailwind CSS

**データベース**
- Supabase

**ストレージ**
- Supabase

**ORM**
- Prisma

**ホスティング**
- Vercel

**その他**
- LIFF（LINEログインチャネル）
- LINE Official Account Manager
- Line Messaging API
- Git
- Github


# 構成図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/06afdb9c-e65e-c335-c12c-fefc2b43af8a.png)


# 解説

### 請求書の画像生成
今回のアプリでは、ユーザーからの入力に応じて請求書の画像を動的に生成したいので、@vercel/og (Vercel の OG Image Generation) を利用してAPIを作成しました。

参考記事

https://www.newt.so/docs/tutorials/nextjs-og-image-generation

```src/app/api/og/invoice/route.ts
import { NextRequest } from "next/server";
import { ImageResponse } from "@vercel/og";
import { InvoiceImage } from "@/components/InvoiceImage";

export const runtime = "edge";

export function GET(req: NextRequest) {
  if (req.method !== "GET") {
    return new Response("Method Not Allowed", { status: 405 });
  }

  try {
    const { searchParams } = new URL(req.url);

    const issueDate = searchParams.get("issueDate") || "";
    const dueDate = searchParams.get("dueDate") || "";
    const amount = searchParams.get("amount") || "";
    const message = searchParams.get("message") || "";
    const recipient = searchParams.get("recipient") || "";
    const hankoImage = searchParams.get("hankoImage") || "";
    const decodedHankoImage = decodeURIComponent(hankoImage);

    return new ImageResponse(
      (
        <InvoiceImage
          issueDate={issueDate}
          dueDate={dueDate}
          amount={amount}
          message={message}
          recipient={recipient}
          hankoImage={hankoImage}
          decodedHankoImage={decodedHankoImage}
        />
      ),
      {
        width: 1260,
        height: 960,
      }
    );
  } catch (e: unknown) {
    if (e instanceof Error) {
      console.log(`${e.message}`);
    } else {
      console.log("An unknown error occurred");
    }
    return new Response("画像の生成に失敗しました", {
      status: 500,
    });
  }
}
```
このAPIを使えば、URLパラメータを変更するだけで異なる内容の請求書画像を生成できます。

例えばこのような画像が生成されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/2a1c6e1c-fdac-6322-f20f-ebcdc692d09d.png" width="500px" alt="請求書発行BOT">

### ハンコの画像生成
今回のアプリでは、請求書の画像にLINEのプロフィール写真をハンコ風の画像に変換した画像を載せました。

```src/app/api/hanko/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabaseClient';
import { v4 as uuidv4 } from 'uuid';
import { processHankoImage } from '@/utils/generateHankoUtils';

export async function POST(req: NextRequest) {
  try {
    const authHeader = req.headers.get('authorization');
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return NextResponse.json({ error: '認証が必要です' }, { status: 401 });
    }

    const accessToken = authHeader.split(' ')[1];
    
    // Tokenを使用しLINEプロフィールを取得
    const profileResponse = await fetch('https://api.line.me/v2/profile', {
      headers: {
        'Authorization': `Bearer ${accessToken}`
      }
    });

    const profile = await profileResponse.json();
    const profileImageUrl = profile.pictureUrl;

    const response = await fetch(profileImageUrl);

    const arrayBuffer = await response.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    // 画像処理
    const hankoImage = await processHankoImage(buffer);

    // Supabaseへのアップロード
    const fileName = `${uuidv4()}.png`;
    const { error: uploadError } = await supabase.storage
      .from('hanko-images')
      .upload(fileName, hankoImage, {
        contentType: 'image/png',
      });

    const publicUrlResponse = supabase.storage
      .from('hanko-images')
      .getPublicUrl(fileName);

    return new NextResponse(JSON.stringify({ 
      imageUrl: publicUrlResponse.data.publicUrl 
      
    }), {
      headers: { 'Content-Type': 'application/json' },
    });
    
  } catch (error: unknown) {
    console.error('画像処理エラー:', error); 
    return new NextResponse(JSON.stringify({ error: errorMessage }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}
```

このAPIでは、accessTokenを受け取り、そのtokenでLINEプロフィールを取得し、その画像をハンコの画像に変換し、Supabaseのストレージに生成した画像をアップロードしています。そして、アップロードした画像のURLを取得しクライアントに返しています。
請求書にLINEのプロフィール画像のハンコを載せることで、固いイメージのある請求書に少しだけユーモアを出すことができました。

### 履歴ページ
送信した内容を見ることができる履歴ページを作成しました。また、ユーザーが精算済みと未精算を切り替えられたり、履歴を削除したりすることができるようにしました。データベースはSupabaseを使用しています。
ユーザーの情報はLIFF経由で都度LINEプラットフォームから取得しています。

履歴画面です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/90fe6762-39c6-f10d-2fd0-a67bd348d3af.png" width="250px" alt="請求書発行BOT">　　<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/72a9f2fd-ef28-b240-61e5-f24ced8b06ae.png" width="250px" alt="請求書発行BOT">

### LIFF連携
LINEアプリ内での使用を想定しているので、作るWebアプリとLIFFアプリを連携します。
LIFFアプリを作成するためにLINEログインチャネルを作成します。（LINEログインとLINEミニアプリ以外のチャネルにはLIFFアプリを追加できないみたいです。）

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

このページにLINEアカウントでログインし、プロバイダーを作成し、そのプロバイダーの中にLINEログインチャネルを作成します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/eb2e2bf1-d882-9379-0dd3-79b8dca974b2.png" width="500px" alt="請求書発行BOT">

ログインチャネルを作成したら、「LIFF」タブからLIFFアプリを作成します。
エンドポイントURLにNext.jsで作ったアプリのURLを設定します。
そもそもLIFF連携をしていないとユーザー情報の取得ができないので、この設定は最初にする必要があります。

### LINE公式アカウントの作成

https://manager.line.biz/

このページにLINEアカウントでログインし、公式LINEアカウントを作成し、作成したLIFFアプリを埋め込みます。今回はリッチメニューから作成したLIFFアプリにアクセスできるようにしました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/19688fe0-8f1f-1feb-49ec-600466d80f82.png" width="250px" alt="請求書発行BOT">

# 工夫した点

### liff.initをアプリ起動時のみ行う
最初はページごとでliff.initを実行していましたが、そうするとユーザーがページを移動するごとにliff.initが走ってしまいパフォーマンスの低下につながります。そこで、liff.initを実行したliffオブジェクトの初期化状態をステートとして管理し、それをuseContextを使ってアプリケーション全体で共有することで解決しました。

```src/context/LiffProvider.ts
"use client";

import React, { createContext, useState, useEffect, ReactNode, useContext } from 'react';
import liff, { Liff } from '@line/liff';

type LiffContextType = {
  liff: Liff | null;
  isLoggedIn: boolean;
  isInitialized: boolean;
  error: Error | null;
};

const initialContext: LiffContextType = {
  liff: null,
  isLoggedIn: false,
  isInitialized: false,
  error: null,
};

const LiffContext = createContext<LiffContextType>(initialContext);

export const LiffProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [state, setState] = useState<Omit<LiffContextType>>(initialContext);

  useEffect(() => {
    const initializeLiff = async () => {
      try {
        const liffId = process.env.NEXT_PUBLIC_LIFF_ID;
        if (!liffId) {
          throw new Error('LIFF IDが必要です');
        }

        await liff.init({ liffId });

        if (!liff.isLoggedIn()) {
          liff.login();
        }

        setState({
          liff,
          isLoggedIn: liff.isLoggedIn(),
          isInitialized: true,
          error: null,
        });
        
      } catch (error) {
      
        (略)
        
        }));
      }
    };

    if (!state.isInitialized) {
      initializeLiff();
    }
  }, []);

  return (
    <LiffContext.Provider value={contextValue}>
      {children}
    </LiffContext.Provider>
  );
};

export const useLiff = () => {
  const context = useContext(LiffContext);
  return context;
};
```

### セキュリティ面
LIFFアプリでは、liff.getProfile()を使用してアクセスしているユーザー情報を簡単に取得できます。
LIFFアプリの開発では、ユーザーのLINE情報（userId、プロフィール写真、ユーザー名など）を取得して活用したいケースが多々あります。しかし、公式ドキュメントにもある通り、これらの情報を直接自身のサーバーに送信することは推奨されていません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/34d18032-7b6a-7035-2fe7-43505cfb174c.png)

https://developers.line.biz/ja/docs/liff/using-user-profile/

そこで、accessTokenをLINEのプラットフォームから取得できるのですが、そのtokenをサーバーに送信し、サーバー上でそのtokenを使ってLINE APIからユーザー情報を取得することで解決しました。

例えば、ハンコ画像を生成する流れは下の図のようになっていて、クライアントサイドから直接自分のサーバーにプロフィール画像を送信しないようにしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3809870/a7de91db-8955-6dbe-ae7e-156889437d73.png)

# 終わりに
初めて使用した技術を使うこともあり、いろいろ苦戦しましたが、なんとかアプリを形にすることができてよかったです。
インターンやハッカソンでのチーム開発では細かなデザインについては他のメンバーに任せて詳しく触れることがなかったので、今回のアプリ開発で学べることができてよかったです。Tailwind CSSはAIに聞きながら開発しやすく便利でした。
はじめてLINEのアプリを作成してみて、さまざまな機能があることを知り、使いこなせるともっと面白くて便利なアプリを作成できると思いました。