背景
先日、BOT AWARDS 2024 ハッカソン@東京に参加し、その際、LINE Botに前日見た断片的な夢を投げると、それを元にストーリーと夢診断をしてくれるLINE Botアプリを作った。
また、今回はスプレッドシートをDBとして使った。
その備忘録。
全体はこちら
やること
LINEアプリ(LIFF)を使ってみる。
LINEとChatGPTを連携し、Botを作る。
メッセージをGASを使ってスプレッドシートに格納する。
LINE DevelopersやOpenAIの登録
LINE DevelopersやOpenAIの登録が済んでいない場合は、それぞれ登録を済ませる。
LINE Developers の登録
OpenAIの登録
アカウントを作成したら、そこでそれぞれ発行されるAPIキーを利用していきますが、そこらへんについては、いろんな人が解説しているので、省略いたします。
ChatGPTとLINE Botの連携
まずGPTを使ったLINE Botを作成する。
発行されたAPIをenvファイルに記述する。
.env
OPENAI_API_KEY="OpenAI API"
LINE_CHANNEL_SECRET=LINE Messaging API のチャネルシークレット"
LINE_CHANNEL_ACCESS_TOKEN="LINE Messaging API のチャネルアクセストークン"
パッケージをインストール
$ npm install openai
$ npm install @line/bot-sdk express
$ npm install axios
実行プログラム app.js を作成。
app.js
'use strict';
require("dotenv").config();
const { createAndUploadRichMenu } = require('./richMenuSetup');
const { accessSpreadsheet, accessStorySpreadsheet, checkForSimilarDreams, fetchDreamsFromSpreadsheet } = require('./spreadsheet');
const OpenAI = require('openai');
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const express = require('express');
const path = require('path');
const axios = require('axios');
const line = require('@line/bot-sdk');
const PORT = process.env.PORT || 3000; // 今回はngrokでローカルサーバーを立ち上げたため
const config = {
channelSecret: process.env.LINE_CHANNEL_SECRET,
channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN
};
const app = express();
const client = new line.Client(config);
const sessions = {}; //instead of DB
app.get('/', async (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
}); // 無くても構わないが、LINEアプリの見た目をindex.htmlでデザインすることができるので、今回のハッカソンでは、オリジナルのデザインを作成した
app.use('/liff', express.static(__dirname + '/public'))
app.get('/fetchDreams', async (req, res) => {
const userId = req.query.userId;
const dreams = await fetchDreamsFromSpreadsheet(userId);
res.json(dreams);
});
// GPTを使っているのはこっから下
app.post('/webhook', line.middleware(config), (req, res) => {
Promise.all(req.body.events.map(event => {
return handleEvent(event);
})).then((result) => res.json(result));
});
const questions = [
"夢であなたはどこにいましたか?",
"誰が夢に現れましたか?",
"そこであなたはなにをしていましたか?"
];
async function generateFreudianFeedback(story) {
const feedback = await generatePsychologicalInterpretation(story); // generate feedback after analyzing keyword
return feedback;
}
async function generatePsychologicalInterpretation(symbols) {
const prompt = `Interpret these dream symbols psychologically based on Freud's Dream Interpretation in Japanese(日本語で回答): ${symbols}`;
const completion = await openai.chat.completions.create({
model: "gpt-3.5-turbo",
messages: [{ role: "system", content: prompt }]
});
return completion.choices[0].message.content;
}
async function handleEvent(event) {
if (event.type !== 'message' || event.message.type !== 'text') {
return Promise.resolve(null);
}
const userId = event.source.userId;
const text = event.message.text;
await accessSpreadsheet({ UserId: userId, Text: text, Timestamp: new Date().toISOString() });
if (text === "夢日記をはじめます。") {
sessions[userId] = {
userId: userId,
responses: [],
questionIndex: 0
};
return client.replyMessage(event.replyToken, { type: 'text', text: "夢であなたはどこにいましたか?" });
}
if (!sessions[userId]) {
sessions[userId] = {
userId: userId,
responses: [],
questionIndex: 0 // question index
};
}
const userSession = sessions[userId];
if (userSession.questionIndex < questions.length) {
userSession.responses.push(text); // save user's answer
userSession.questionIndex++; // update question index
}
if (userSession.questionIndex < questions.length) {
// next question
return client.replyMessage(event.replyToken, { type: 'text', text: questions[userSession.questionIndex] });
} else {
// generate the story once the questions are completed
const story = await generateStory(userSession.responses);
const illustrationUrl = await generateIllustration(story);
const feedback = await generateFreudianFeedback(story);
sessions[userId] = null; // reset the session
await accessStorySpreadsheet({ UserId: userId, Story: story, Timestamp: new Date().toISOString(), Feedback: feedback });
const similarDreamsCount = await checkForSimilarDreams(story, new Date().toDateString());
if (illustrationUrl) {
// send url of text and illustration to LINE
await client.replyMessage(event.replyToken, [
{ type: 'text', text: story },
{ type: 'image', originalContentUrl: illustrationUrl, previewImageUrl: illustrationUrl },
{ type: 'text', text: `フロイト夢分析:\n\n${feedback}` },
{ type: 'text', text: `あなたの他に、今日同じ夢を見た人が${similarDreamsCount - 1}人いました` },
]);
} else {
// if it's failed
return client.replyMessage(event.replyToken, { type: 'text', text: story });
}
}
}
async function generateIllustration(prompt) {
try {
const response = await openai.images.generate({
model: "dall-e-3",
prompt: prompt,
n: 1, // the number of images
size: "1024x1024" // size of image
});
return response.data[0].url; // return url of generated image
} catch (error) {
console.error('Error generating illustration:', error);
return null;
}
}
async function generateStory(responses) {
if (responses.length < 3) {
// Error handling
return "Error: Not enough data to generate a story. Please provide three parts.";
}
// Create the prompt based on user's answer
const prompt = `Create a short story based on these elements in Japanese:\n1. Dream about: ${responses[0]}\n2. With: ${responses[1]}\n3. Doing: ${responses[2]}\n\nStory:`;
const completion = await openai.chat.completions.create({
model: "gpt-3.5-turbo",
messages: [{ role: "system", content: prompt }]
});
return completion.choices[0].message.content;
}
app.listen(PORT, () => {
console.log(`Server running at ${PORT}`);
createAndUploadRichMenu().then(richMenuId => {
console.log('Rich menu is set up with ID:', richMenuId);
}).catch(err => {
console.error('Failed to set up rich menu:', err);
});
});
Webhook設定(LINE Developers)
LINE Developers
Messaging API設定 > WebhookURL > 編集
ここに先ほどのアドレスをペーストし、末尾に /webhook を付けたすことを忘れないように。(各々作成したAPIに合わせる必要がある)
動作テスト
ローカルサーバーで試すためにngrokを使った。
ngrokをインストール
$ npm install -g ngrok
ngrokを起動
$ ngrok http 3000