2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js + Vercel で LINE × ChatGPT ボットを作る【GPT-5 nano使用】

Posted at

はじめに

この記事では、LINEアプリ上でChatGPTと会話できるボットを作成します。

友達や家族と気軽にシェアできて、スマホから簡単にAIアシスタントを使えます!

完成品の機能

  • LINEでChatGPTと自然な会話
  • 画像を送ると内容を説明してくれる
  • 前回の会話を記憶(文脈理解)
  • 使用制限機能(1日50メッセージまで)
  • データベース不要のシンプル実装
  • 完全無料で運用可能(Vercel無料枠)

デモ

line-chat-demo.png
実際のLINE画面での会話例

技術スタック

  • Next.js 14
  • TypeScript
  • Vercel(デプロイ)
  • LINE Messaging API
  • OpenAI GPT-5 nano

必要なもの

アカウント・APIキー

  1. LINE Developers アカウント

  2. OpenAI API キー

  3. Vercel アカウント


プロジェクト作成

Next.jsプロジェクトを初期化

npx create-next-app@latest line-chatgpt-bot
cd line-chatgpt-bot

オプション選択:

  • TypeScript: Yes
  • Tailwind CSS: Yes
  • App Router: Yes

必要なパッケージをインストール

npm install @line/bot-sdk openai

環境変数を設定

.env.local を作成:

LINE_CHANNEL_ACCESS_TOKEN=your_line_channel_access_token
LINE_CHANNEL_SECRET=your_line_channel_secret
OPENAI_API_KEY=your_openai_api_key
MAX_MESSAGES_PER_DAY=50
MAX_CONVERSATION_HISTORY=5

実装

メモリ管理(会話履歴・使用制限)

lib/memory.ts を作成:

interface Message {
  role: 'user' | 'assistant';
  content: string;
  timestamp: number;
}

interface UserData {
  messages: Message[];
  dailyCount: number;
  lastResetDate: string;
}

const userDataMap = new Map<string, UserData>();
const MAX_CONVERSATION_HISTORY = parseInt(process.env.MAX_CONVERSATION_HISTORY || '5');
const MAX_MESSAGES_PER_DAY = parseInt(process.env.MAX_MESSAGES_PER_DAY || '50');

export function getConversationHistory(userId: string): Message[] {
  const userData = getUserData(userId);
  return userData.messages;
}

export function addMessage(userId: string, role: 'user' | 'assistant', content: string): void {
  const userData = getUserData(userId);
  userData.messages.push({ role, content, timestamp: Date.now() });
  
  const maxMessages = MAX_CONVERSATION_HISTORY * 2;
  if (userData.messages.length > maxMessages) {
    userData.messages.splice(0, userData.messages.length - maxMessages);
  }
}

export function canSendMessage(userId: string): boolean {
  const userData = getUserData(userId);
  return userData.dailyCount < MAX_MESSAGES_PER_DAY;
}

export function incrementMessageCount(userId: string): void {
  const userData = getUserData(userId);
  userData.dailyCount++;
}

function getUserData(userId: string): UserData {
  if (!userDataMap.has(userId)) {
    userDataMap.set(userId, {
      messages: [],
      dailyCount: 0,
      lastResetDate: new Date().toISOString().split('T')[0],
    });
  }
  
  const userData = userDataMap.get(userId)!;
  const today = new Date().toISOString().split('T')[0];
  if (userData.lastResetDate !== today) {
    userData.dailyCount = 0;
    userData.lastResetDate = today;
  }
  
  return userData;
}

OpenAI APIラッパー

lib/openai.ts を作成:

重要: GPT-5 nanoは推論モデルのため、特殊なパラメータ設定が必要です。

import OpenAI from 'openai';

export const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY || '',
});

interface Message {
  role: 'user' | 'assistant' | 'system';
  content: string;
}

export async function chatWithGPT(messages: Message[]): Promise<string> {
  try {
    const response = await openai.chat.completions.create({
      model: 'gpt-5-nano',
      messages: messages,
      max_completion_tokens: 16000,
    });

    const content = response.choices[0]?.message?.content;
    
    if (!content) {
      return 'すみません、応答を生成できませんでした。';
    }
    
    return content;
  } catch (error) {
    console.error('OpenAI API Error:', error);
    throw new Error('ChatGPTとの通信中にエラーが発生しました。');
  }
}

export async function chatWithImage(text: string, imageBase64: string): Promise<string> {
  try {
    const response = await openai.chat.completions.create({
      model: 'gpt-5-nano',
      messages: [
        {
          role: 'user',
          content: [
            { type: 'text', text: text },
            { 
              type: 'image_url', 
              image_url: { 
                url: `data:image/jpeg;base64,${imageBase64}` 
              } 
            },
          ],
        },
      ],
      max_completion_tokens: 16000,
    });

    return response.choices[0]?.message?.content || '画像を認識できませんでした。';
  } catch (error) {
    console.error('OpenAI Vision API Error:', error);
    throw new Error('画像認識中にエラーが発生しました。');
  }
}

GPT-5 nano の注意点:

  • max_tokens は非対応 → max_completion_tokens を使用
  • temperature のカスタマイズ不可 → デフォルト値(1)のみ
  • 推論モデルのため、大きなトークン数が必要(16000推奨)

LINE APIラッパー

lib/line.ts を作成:

重要: ビルド時エラーを防ぐため、遅延初期化を使用します。

import { Client, ClientConfig } from '@line/bot-sdk';

let lineClientInstance: Client | null = null;

function getLineClient(): Client {
  if (!lineClientInstance) {
    const config: ClientConfig = {
      channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN || '',
      channelSecret: process.env.LINE_CHANNEL_SECRET || '',
    };
    lineClientInstance = new Client(config);
  }
  return lineClientInstance;
}

export async function replyTextMessage(replyToken: string, text: string) {
  const client = getLineClient();
  return await client.replyMessage(replyToken, {
    type: 'text',
    text: text,
  });
}

export async function getImageContent(messageId: string): Promise<Buffer> {
  const client = getLineClient();
  const stream = await client.getMessageContent(messageId);
  const chunks: Buffer[] = [];
  
  return new Promise((resolve, reject) => {
    stream.on('data', (chunk: Buffer) => chunks.push(chunk));
    stream.on('end', () => resolve(Buffer.concat(chunks)));
    stream.on('error', reject);
  });
}

Webhook API(メインロジック)

app/api/webhook/route.ts を作成:

重要: 推論トークン削減のため、会話履歴は最新1往復のみに制限します。

import { NextRequest, NextResponse } from 'next/server';
import { WebhookEvent } from '@line/bot-sdk';
import { replyTextMessage, getImageContent } from '@/lib/line';
import { chatWithGPT, chatWithImage } from '@/lib/openai';
import {
  getConversationHistory,
  addMessage,
  canSendMessage,
  incrementMessageCount,
} from '@/lib/memory';

export async function POST(req: NextRequest) {
  try {
    const body = await req.text();
    const signature = req.headers.get('x-line-signature');

    // 署名検証
    if (!signature) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const channelSecret = process.env.LINE_CHANNEL_SECRET || '';
    const crypto = require('crypto');
    const hash = crypto.createHmac('SHA256', channelSecret).update(body).digest('base64');

    if (hash !== signature) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }
    
    const data = JSON.parse(body);
    const events: WebhookEvent[] = data.events || [];

    await Promise.all(events.map(handleEvent));

    return NextResponse.json({ status: 'ok' });
  } catch (error) {
    console.error('Webhook Error:', error);
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
  }
}

async function handleEvent(event: WebhookEvent) {
  if (event.type !== 'message') return;
  const userId = event.source.userId;
  if (!userId) return;

  if (!canSendMessage(userId)) {
    await replyTextMessage(event.replyToken, '本日の利用上限に達しました');
    return;
  }

  if (event.message.type === 'text') {
    await handleTextMessage(event, userId);
  } else if (event.message.type === 'image') {
    await handleImageMessage(event, userId);
  }
}

async function handleTextMessage(event: any, userId: string) {
  const userMessage = event.message.text;
  const history = getConversationHistory(userId);
  
  addMessage(userId, 'user', userMessage);

  // 推論トークン削減のため、最新1往復のみ使用
  const recentHistory = history.slice(-2);

  const messages = [
    { role: 'system' as const, content: 'あなたは親切で役立つAIアシスタントです。' },
    ...recentHistory.map(msg => ({ role: msg.role, content: msg.content })),
    { role: 'user' as const, content: userMessage },
  ];

  const reply = await chatWithGPT(messages);

  addMessage(userId, 'assistant', reply);
  incrementMessageCount(userId);

  await replyTextMessage(event.replyToken, reply);
}

async function handleImageMessage(event: any, userId: string) {
  const imageBuffer = await getImageContent(event.message.id);
  const imageBase64 = imageBuffer.toString('base64');

  const reply = await chatWithImage('この画像について説明してください。', imageBase64);

  addMessage(userId, 'user', '[画像を送信]');
  addMessage(userId, 'assistant', reply);
  incrementMessageCount(userId);

  await replyTextMessage(event.replyToken, reply);
}

会話履歴を1往復に制限した理由:

  • GPT-5 nanoは推論モデルで、内部思考に大量のトークンを使用
  • 会話履歴が多いと推論トークンが増え、出力用のトークンが残らない
  • 最新1往復のみに制限することで、推論トークンを削減

Vercelにデプロイ

GitHubにプッシュ

git init
git add .
git commit -m "Add LINE ChatGPT Bot with GPT-5 nano"
git branch -M main
git remote add origin https://github.com/your-username/line-chatgpt-bot.git
git push -u origin main

Vercelで連携

  1. https://vercel.com/ にアクセス
  2. Import Project をクリック
  3. GitHubリポジトリを選択
  4. 環境変数を必ず設定(全環境にチェック):
    • LINE_CHANNEL_ACCESS_TOKEN
    • LINE_CHANNEL_SECRET
    • OPENAI_API_KEY
    • MAX_MESSAGES_PER_DAY
    • MAX_CONVERSATION_HISTORY
    • Production, Preview, Development 全てにチェックを入れる
  5. Deploy をクリック

LINE側の設定

LINE公式アカウント作成

  1. https://developers.line.biz/console/ にアクセス
  2. Business IDを作成(個人利用でもOK)
  3. Providerを作成
  4. Messaging API チャネルを作成

Messaging APIを有効化

  1. LINE Official Account Manager で設定を開く
  2. 設定 → Messaging API
  3. 「Messaging APIを利用する」をクリック
  4. Providerを選択

Webhook URLを設定

  1. LINE Developers Console → Messaging API タブ
  2. Webhook URL に以下を入力:
https://your-app-name.vercel.app/api/webhook
  1. 「検証」をクリック → 成功を確認
  2. 「Webhookの利用」を ON にする

応答設定

最重要: 自動応答をOFFにしないとWebhookが動きません!

LINE Official Account Manager で:

  1. 設定 → 応答設定
  2. チャット: ON
  3. Webhook: ON
  4. 応答メッセージ: チャットONで自動的にOFF

GPT-5 nano 特有の注意点

推論モデルの特性

GPT-5 nanoは推論専用モデルです。従来のモデルとは異なります。

パラメータの違い

従来のモデル:

max_tokens: 1000,
temperature: 0.7,

GPT-5 nano:

max_completion_tokens: 16000,  // 大きな値が必要!
// temperature は指定しない

推論トークンとは

GPT-5 nanoは内部で「考える」プロセスがあります:

質問: 「Pythonについて教えて」
  ↓
内部思考(推論トークン):
  「Pythonの特徴は...初心者向けに...
   具体例を入れて...どう説明するか...」
  ↓ 数千トークン消費
実際の出力:
  「Pythonはプログラミング言語で...」

そのため、max_completion_tokens を大きく設定する必要があります。

会話履歴の制限

推論トークンを削減するため、会話履歴は最新1往復のみに制限:

const recentHistory = history.slice(-2); // 最新1往復

動作確認

QRコードで友だち追加

LINE Developersの Messaging API タブにあるQRコードをスキャン

メッセージを送信

あなた: こんにちは!
Bot: こんにちは!何かお手伝いできることはありますか?

画像を送信

写真を送ると、AIが内容を詳しく説明してくれます!
image-recognition-demo.png
実際のLINE画面での会話例


トラブルシューティング

ビルドエラー

問題: "no channel access token"

解決: LINE SDKの遅延初期化を実装

Webhook 401 エラー

原因:

  1. 環境変数が未設定
  2. 環境変数が Preview 環境に適用されていない

解決:

  1. Vercel → Settings → Environment Variables
  2. 全環境(Production, Preview, Development)にチェック

空の応答が返る

原因: 推論トークンが多すぎて出力用トークンが残らない

解決:

  1. max_completion_tokens を 16000 に設定
  2. 会話履歴を最新1往復に制限

費用

実際にかかる費用

項目 費用
Vercel 無料
LINE Messaging API 無料(月1000通まで)
OpenAI GPT-5 nano 従量課金

推論トークンについて:

  • GPT-5 nanoは推論に大量のトークンを使用
  • 1メッセージあたり5000〜15000トークン消費
  • 従来モデルより費用が高くなる可能性あり
  • 使用量制限の設定を推奨

まとめ

学んだこと

  • GPT-5 nanoは推論モデルで特殊なパラメータが必要
  • max_completion_tokens を大きく設定する
  • 会話履歴を制限して推論トークンを削減
  • 遅延初期化でビルドエラーを回避
  • 環境変数は全環境に設定が必要
  • 署名検証でセキュリティを確保

Next.js + Vercel の組み合わせで、デプロイが超簡単でした!

ぜひ作ってみてください!


ソースコード

完全なソースコードはGitHubで公開しています:


参考文献


著者について

SNAMO(@snamo-suzuki

フルスタックエンジニア / AI統合開発専門家

専門分野

  • 🤖 AI統合開発: ChatGPT・Google AI・Claude統合システムの設計・開発
  • 🎬 AI動画生成: Runway API等の最新AI技術
  • ⚛️ フルスタック開発: Next.js/React, Supabase, pgvector
  • 🔍 レリバンスエンジニアリング: AI検索時代のSEO戦略・GEO対策

サービス

  • 💼 企業のDX推進・AI技術導入コンサルティング
  • 💬 無料相談受付中

リンク

この記事がお役に立ちましたら、いいね・ストックをお願いします!

ご質問やご相談があれば、コメント欄またはポートフォリオサイトからお気軽にお問い合わせください 😊

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?