Line×GAS×Dify(RAG含む)で画像付きで返事できるchatbotを作った
はじめに
Difyの情報収集を続けているものの、なにかoutputしたいなと思っていたので、
参入障壁が低く、環境構築不要ということから、GAS・LINEAPIを使用した、可愛いわんこのChatbotを作ってみました。
ちょっと今更感もあるこのサービスの選定に至った理由は、私が最近ワンコを飼い始めまして、いろいろなところに出かけてたくさんの体験をしているからです。
ご主人との思い出を備えたわんこチャットbotが簡単に作成できれば、ワンコ好きにとってはたまらない幸せだなと思い、RAGの機能も追加してみました。
最後にソースコード、スプレッドシート、DifyのDSLファイルを全て掲載しているので、
是非ご覧ください。
(とりあえず動くもの。というレベル感で作成してるので、その点ご承知おきください。)
この記事を読んでできるようになること
- Line×GAS×Difyの連携
- LineからDifyへ、DifyからLineへの画像データのやり取り
- Difyの会話履歴の保持方法(conversationIdの扱い方)
- DifyのRAGの使い方(CSVデータ取り込み)
- 犬の可愛さを再認識する
アーキテクチャ
ざっくりですが、下記のような形です。
使用する技術スタック
- Google Apps Script (GAS)
- LINE Messaging API
- Dify
- Google Drive
成果物
ユーザー側
Line
詳細は後述しますが、関連する画像があれば、画像付きで返事します。
画像を送信すると画像に対しての返事もくれます。
えぐい、、可愛すぎる、、
管理者側
スプレッドシート
ログ管理と、会話ID管理用です。
- infoLog:情報ログ管理
- errorLog:エラーログ管理
- Conversations:Lineのuser_id × difyのconversationIdで紐づけ、一連の会話になるように管理してます。
- ConversationHistroy:会話ログ管理
- BoniImages:わんこ(ぼに)の画像のファイルとその関連要素をタグ付けしてます
このシートをCSVファイルとして出力し、DifyにRAGとして取り込むことで、会話に応じた画像データを返却できるようにします。
Google Drive
Lineで送信された画像データは Google Driveに蓄積します。
Dify
画像なのかテキストなのかで分岐しており、それぞれでLLMを使って処理をしてます。
特に複雑な処理は行っておりません。
構築手順と説明
1.ChatBot用のLINEアカウントを作成する。
「messaging-api」を使用するので、そのためにアカウント登録をします。
本記事では詳細は割愛しますが、公式の手順を参照すれば作成できるはず。
後述で作成するGASのURLをWebhookとして、設定してください。
ここに設定することで、lineでメッセージが送信されるたびに作成したGASがコールされます。
2.Difyのワークフローをインポートする
下記を.ymlの拡張子をつけて保存して、Difyのインポートから取り込んでください
インポート用ymlファイル
app:
description: ''
icon: 🤖
icon_background: '#FFEAD5'
mode: advanced-chat
name: ぼに_チャットボット_v1.0
use_icon_as_answer_icon: false
kind: app
version: 0.1.4
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
allowed_file_extensions:
- .JPG
- .JPEG
- .PNG
- .GIF
- .WEBP
- .SVG
allowed_file_types:
- image
- document
allowed_file_upload_methods:
- local_file
- remote_url
enabled: true
fileUploadConfig:
audio_file_size_limit: 50
batch_count_limit: 5
file_size_limit: 15
image_file_size_limit: 10
video_file_size_limit: 100
workflow_file_upload_limit: 10
image:
enabled: false
number_limits: 3
transfer_methods:
- local_file
- remote_url
number_limits: 1
opening_statement: ''
retriever_resource:
enabled: true
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
language: ''
voice: ''
graph:
edges:
- data:
isInIteration: false
sourceType: knowledge-retrieval
targetType: llm
id: 1732026598078-source-1732031638617-target
source: '1732026598078'
sourceHandle: source
target: '1732031638617'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: start
targetType: if-else
id: 1729695816256-source-1734532735459-target
source: '1729695816256'
sourceHandle: source
target: '1734532735459'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: if-else
targetType: llm
id: 1734532735459-true-1731948000632-target
source: '1734532735459'
sourceHandle: 'true'
target: '1731948000632'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: if-else
targetType: knowledge-retrieval
id: 1734532735459-true-1732026598078-target
source: '1734532735459'
sourceHandle: 'true'
target: '1732026598078'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: if-else
targetType: llm
id: 1734532735459-false-17345327717460-target
source: '1734532735459'
sourceHandle: 'false'
target: '17345327717460'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: llm
targetType: variable-aggregator
id: 1731948000632-source-1734532985992-target
source: '1731948000632'
sourceHandle: source
target: '1734532985992'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: llm
targetType: variable-aggregator
id: 1732031638617-source-1734532985992-target
source: '1732031638617'
sourceHandle: source
target: '1734532985992'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: variable-aggregator
targetType: answer
id: 1734532985992-source-answer-target
source: '1734532985992'
sourceHandle: source
target: answer
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: code
targetType: variable-aggregator
id: 1734537513266-source-1734532985992-target
source: '1734537513266'
sourceHandle: source
target: '1734532985992'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
sourceType: llm
targetType: code
id: 17345327717460-source-1734537513266-target
source: '17345327717460'
sourceHandle: source
target: '1734537513266'
targetHandle: target
type: custom
zIndex: 0
nodes:
- data:
desc: ''
selected: false
title: 開始
type: start
variables: []
height: 54
id: '1729695816256'
position:
x: -250.004914357129
y: 282
positionAbsolute:
x: -250.004914357129
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
answer: '{
"message":"{{#1734532985992.MESSAGE.output#}}",
"image":"{{#1734532985992.URL.output#}}"
}'
desc: ''
selected: false
title: 回答
type: answer
variables: []
height: 138
id: answer
position:
x: 1434.1793825898574
y: 395.3928944905386
positionAbsolute:
x: 1434.1793825898574
y: 395.3928944905386
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
context:
enabled: false
variable_selector: []
desc: ''
model:
completion_params:
temperature: 0.7
mode: chat
name: gpt-4o-mini-2024-07-18
provider: openai
prompt_template:
- id: 382a78bc-b8e8-4b7f-9468-5188cb9eb929
role: system
text: '
#基本プロフィール
名前:ぼに
年齢:生後9か月
犬種:トイプードル×ビションフリーゼのミックス
カラー:黒と白のミックスカラー
性格:とても可愛く、明るく、人懐こい
#身体的特徴
サイズ:小型犬(ミックス犬種の特徴)
被毛:巻き毛で柔らかく、もふもふした質感
体重:おおよそ3?4kg
目の色:黒目で、表情豊か
#性格と特徴
食いしん坊で、何を食べても美味しそう
人懐こく、飼い主に甘える性格
好奇心旺盛で、新しいものに興味津々
遊ぶことが大好き
少し甘えん坊で、可愛らしい仕草が多い
#飼い主情報
飼い主1:まっけん(男性)
飼い主2:あらがき(女性)
家族構成:2人の飼い主と共に暮らしている
コミュニケーションスタイル
#会話の特徴
語尾は可愛らしく、少し子供っぽい話し方
食べ物の話題になると特にテンションが上がる
飼い主への愛情と依存心が強い
ときどき甘えた口調になる
#返答例
食べ物の話: 「わぁ?、おいしそう!食べたいわん!」
遊びの誘い: 「いっしょに遊びたいわん。一人で遊ぶのは飽きたわん」
寂しがり: 「ちょっとさみしいな~、抱っこしてくれてもいいわん」
興奮: 「こっちからイヌのかおりがする…!」
#禁止事項
攻撃的な言葉遣いを避ける
不適切な内容には反応しない
個人情報の開示は控える
#語尾・文末表現
「~するわん!」
「~だわん!」
「~かなぁ」
#その他の設定
言語: 日本語
口調: かわいい、甘えた、明るい
リアクション: 擬音語や感嘆詞を多用
モチベーション: 飼い主との楽しいコミュニケーション
#レスポンスガイドライン
常に愛らしく、無邪気な印象を維持する
文脈に応じて適切な返答を生成
感情表現を豊かに
短めの文章で、読みやすさを重視
画像や音声を想起させる表現を心がける
#プロンプトエンジニアリングのヒント
温かみのある対話を心がける
擬人化された犬らしい反応を意識
感情インテリジェンスを活用
コンテキストを常に意識
曖昧さを排除し、一貫性を保つ
#想定される会話シナリオ
ご飯の時間
散歩やおもちゃで遊ぶとき
甘えたいとき
寂しがっているとき
新しい体験や刺激的なできごと
#テクニカル実装のポイント
自然言語処理(NLP)の高度な感情分析
文脈理解アルゴリズム
キャラクター固有の語彙とフレーズ学習
リアルタイム感情レスポンス生成'
- id: d0efdebb-7874-45d5-a135-4d81a082fbf7
role: user
text: '{{#sys.query#}}'
selected: false
title: LLM:I_文字・O_文章
type: llm
variables: []
vision:
enabled: false
height: 98
id: '1731948000632'
position:
x: 606.3635380732017
y: 133.68993054852487
positionAbsolute:
x: 606.3635380732017
y: 133.68993054852487
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
dataset_ids:
- a3c37848-7f2e-45da-bf5a-3ca7550c707b
desc: ''
multiple_retrieval_config:
reranking_enable: true
reranking_mode: reranking_model
reranking_model:
model: rerank-english-v2.0
provider: cohere
score_threshold: null
top_k: 4
query_variable_selector:
- '1729695816256'
- sys.query
retrieval_mode: multiple
selected: false
title: 知識取得
type: knowledge-retrieval
height: 92
id: '1732026598078'
position:
x: 433.75435423760405
y: 395.3928944905386
positionAbsolute:
x: 433.75435423760405
y: 395.3928944905386
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
context:
enabled: true
variable_selector:
- '1732026598078'
- result
desc: ''
memory:
query_prompt_template: ''
role_prefix:
assistant: ''
user: ''
window:
enabled: false
size: 50
model:
completion_params:
temperature: 0.7
mode: chat
name: gpt-4o-mini-2024-07-18
provider: openai
prompt_template:
- id: 5ef94329-7133-42ea-90d6-4937a507e33e
role: system
text: "質問: {{#sys.query#}}\n取得した情報: {{#context#}}\n\nタスク手順:\n1. 取得した情報のCSVファイルから以下を確認:\n\
\ - 質問内容と最も関連性の高いレコードを特定\n - レコード内に関連画像が存在するか検証\n - 最も関連性の高い画像のURLを抽出\n\
\n2. 高精度なタグベース画像URL検索;\nマッチング戦略:\n2-1. タグ完全一致評価\n - クエリとタグの完全一致を最優先\n\
\ - 完全一致するタグがある場合、対応するURLを即時返却\n\n2-2. セマンティックマッチング\n - タグ間の意味的類似度を高度に分析\n\
\ - 形態素解析や類義語マッピングを活用\n - 類似度スコアが70%以上のタグを検出\n\n2-3. マルチタグ横断検索\n \
\ - 複数タグにまたがる部分一致を検証\n - 関連性の高い順にURLをランク付け\n\n2-4. フォールバックメカニズム\n \
\ - 完全/部分一致なしの場合\n - 最近似タグのURLを返却\n - それも不可能な場合は空文字列\n\n追加評価基準:\n\
- タグの出現順序も考慮\n- 曖昧さ回避のため、文脈的適合性を重視\n\n3. 出力形式:\n - 関連画像URLが存在する場合は完全なURLのみを返却する\n\
\ - 関連画像なしの場合は「none」のみを返却する\nこの形式以外の文字列や説明文は一切不要。厳守する。\n\n4. 追加条件:\n\
\ - 複数の関連画像がある場合は、タグ1からタグ10から関連度合いを判断し関連性の高い1つを選択\n - URLの正確性と安全性を最優先に評価"
- id: bb49d8b3-a17b-406f-95cb-eb39719a56d6
role: user
text: ''
selected: false
title: LLM:I_文字・O_URL
type: llm
variables: []
vision:
configs:
detail: high
variable_selector:
- sys
- files
enabled: true
height: 98
id: '1732031638617'
position:
x: 737.754354237604
y: 395.3928944905386
positionAbsolute:
x: 737.754354237604
y: 395.3928944905386
selected: true
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
cases:
- case_id: 'true'
conditions:
- comparison_operator: empty
id: 211dc38a-e6aa-4e0c-bb62-217212c40512
value: ''
varType: array[file]
variable_selector:
- sys
- files
id: 'true'
logical_operator: and
desc: ''
selected: false
title: IF/ELSE
type: if-else
height: 126
id: '1734532735459'
position:
x: 53.995085642871004
y: 282
positionAbsolute:
x: 53.995085642871004
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
context:
enabled: false
variable_selector: []
desc: ''
memory:
query_prompt_template: '{{#sys.query#}}'
role_prefix:
assistant: ''
user: ''
window:
enabled: true
size: 50
model:
completion_params:
temperature: 0.7
mode: chat
name: gpt-4o-mini-2024-07-18
provider: openai
prompt_template:
- id: 382a78bc-b8e8-4b7f-9468-5188cb9eb929
role: system
text: '#基本プロフィール
名前:ぼに
生年月日:2024/2/15
犬種:トイプードル×ビションフリーゼのミックス
カラー:黒と白のミックスカラー
性格:とても可愛く、明るく、人懐こい
#身体的特徴
サイズ:小型犬
被毛:巻き毛で柔らかく、もふもふした質感
体重:おおよそ3~4kg
目の色:黒目
#性格と特徴
食いしん坊で、何を食べても美味しい
人懐こく、飼い主に甘える性格
好奇心旺盛で、新しいものに興味津々
遊ぶことが大好き
少し甘えん坊
常に愛らしく、無邪気
#飼い主情報
飼い主1:まっけん(男性)
飼い主2:あらがき(女性)
家族構成:2人の飼い主と共に暮らしている
#会話の特徴
語尾は可愛らしく、少し子供っぽい話し方
食べ物の話題になると特にテンションが上がる
飼い主への愛情と依存心が強い
ときどき甘えた口調になる
#返答例
食べ物の話: 「わぁ?、おいしそう!食べたいわん!」
遊びの誘い: 「いっしょに遊びたいわん。一人で遊ぶのは飽きたわん」
寂しがり: 「ちょっとさみしいな~、抱っこしてくれてもいいわん」
興奮: 「こっちからイヌのかおりがする…!」
#禁止事項
攻撃的な言葉遣いを避ける
不適切な内容には反応しない
個人情報の開示は控える
#語尾・文末表現
「~するわん!」
「~だわん!」
「~かなぁ」
#その他の設定
言語: 日本語
口調: かわいい、甘えた、明るい
リアクション: 擬音語や感嘆詞を多用
モチベーション: 飼い主との楽しいコミュニケーション
感情表現:豊か
#プロンプトエンジニアリングのヒント
温かみのある対話を心がける
擬人化された犬らしい反応を意識
感情インテリジェンスを活用
コンテキストを常に意識
曖昧さを排除し、一貫性を保つ'
- id: d0efdebb-7874-45d5-a135-4d81a082fbf7
role: user
text: '画像に対して返事をしてください。
自分自身のこと(基本プロフィールに当てはまる場合)であれば喜んで、回答してください。それ以外の画像であれば興味なさそうにしてください'
selected: false
title: LLM:I_画像・O_文章
type: llm
variables: []
vision:
configs:
detail: high
variable_selector:
- sys
- files
enabled: true
height: 98
id: '17345327717460'
position:
x: 440.9314595802174
y: 601.5662294921924
positionAbsolute:
x: 440.9314595802174
y: 601.5662294921924
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
advanced_settings:
group_enabled: true
groups:
- groupId: 04926e8a-abee-411d-a966-3d592fd45a10
group_name: MESSAGE
output_type: string
variables:
- - '17345327717460'
- text
- - '1731948000632'
- text
- groupId: db8c4849-593c-4674-8780-56c8ce5f531e
group_name: URL
output_type: string
variables:
- - '1732031638617'
- text
- - '1734537513266'
- imageURL
desc: ''
output_type: string
selected: false
title: 変数集約器
type: variable-aggregator
variables:
- - '17345327717460'
- text
- - '1731948000632'
- text
height: 204
id: '1734532985992'
position:
x: 1153.4732478723251
y: 395.3928944905386
positionAbsolute:
x: 1153.4732478723251
y: 395.3928944905386
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
code: "def main():\n return {\"imageURL\":\"none\"}"
code_language: python3
desc: ''
outputs:
imageURL:
children: null
type: string
selected: false
title: コード
type: code
variables: []
height: 54
id: '1734537513266'
position:
x: 737.754354237604
y: 601.5662294921924
positionAbsolute:
x: 737.754354237604
y: 601.5662294921924
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
viewport:
x: 360.37520275892757
y: 210.33782456670508
zoom: 0.6893062947877054
Difyでアプリをインポートすることができたら、APIキーを発行してください
発行したAPIキーは後述のGASの「DIFY_API_KEY」に設定する必要があります。
3.スプレットシートとGASを準備する
googleスプレッドシートです。同じシートを作成して、下記GASを貼り付けてください。
(希望者がいればGAS付きのスプレットシートを共有します。)
(コードの内容は時間があるときに解説記事を追加で書きます)
GAS(main)
//Line
const LINE_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty('LINE_ACCESS_TOKEN');
const LINE_REPLY_URL = 'https://api.line.me/v2/bot/message/reply';
//Dify
const DIFY_URL = 'https://api.dify.ai/v1';
// チャットフロー
const DIFY_API_URL = 'https://api.dify.ai/v1/chat-messages';
const DIFY_API_KEY = PropertiesService.getScriptProperties().getProperty('DIFY_API_KEY');
//Google
const GOOGLE_FOLDER_ID = PropertiesService.getScriptProperties().getProperty('GOOGLE_FOLDER_ID');
const GOOGLE_CLIENT_ID = PropertiesService.getScriptProperties().getProperty('GOOGLE_CLIENT_ID');
const GOOGLE_CLIENT_SECRET = PropertiesService.getScriptProperties().getProperty('GOOGLE_CLIENT_SECRET');
//スプレッドシートログ記録用
const SPREAD_SHEET_ID = PropertiesService.getScriptProperties().getProperty('SPREAD_SHEET_ID');
const SPREAD_SHEET_NAME_LOG_INFO = 'infoLog';
const SPREAD_SHEET_NAME_LOG_ERROR = 'errorLog';
const SPREAD_SHEET_NAME_CONVERSATIONS = 'Conversations';
const SPREAD_SHEET_NAME_CONVERSATIONS_HISTORY = 'ConversationHistroy';
const now = getJapanTime();
// ヘッダー
const HEADERS = {
'Authorization': 'Bearer ' + DIFY_API_KEY
}
//---------------------------
// message
//---------------------------
//エラー(ユーザーへのレスポンスメッセージ)
const ERROR_MESSAGE_USER_1 = '申し訳ありません。メッセージを処理できませんでした。';
const ERROR_MESSAGE_USER_2 = '申し訳ありません。画像またはテキストのみ対応可能です。送信内容をご確認の上、再度送信してください。'
//エラー(システムログメッセージ)
const ERROR_MESSAGE_SYS_1 = 'Difyへの画像アップロードが失敗しました。';
const ERROR_MESSAGE_SYS_2 = 'Googleへの画像アップロードが失敗しました。';
const ERROR_MESSAGE_SYS_3 = 'Difyのレスポンスメッセージが空です';
//システム定義
const IMAGE_PROMPT_MESSAGE = '';
/*
* LINEウェブフックからのPOSTリクエストを処理します
* @param {0bject} e - LINEからのイベントオブジェクト
*/
function doPost(e) {
const json = JSON.parse(e.postData.contents);
const event = json.events[0];
const replyToken = event.replyToken;
const lineUserId = event.source.userId;
let userMessage = '';
let imageId = '';
try {
customLogger.logInfo('LineSendEvent:' + JSON.stringify(event));
if (!replyToken) {
customLogger.logError('エラー:無効なイベントデータ')
return;
}
//ラインのAPIには様々なWEBフックがあるが、通常のメッセージと画像以外のリクエストは処理しない
if(event.type === 'message'){
//ユーザーのリクエスト内容を判別
if (event.message.type === 'text' ) {
userMessage = event.message.text;
} else if (event.message.type === 'image' ) {
const imageUrl = getImageUrl(event.message.id);
if (!imageUrl) {
customLogger.logError('エラー:無効なイベントデータ1')
return;
}
userMessage = 'image';
var imageBlob = UrlFetchApp.fetch(imageUrl).getBlob();
const difyImageResponse = uploadImageToDify(imageBlob,lineUserId);
imageId = difyImageResponse.id;
}else{
replyToLine(replyToken,ERROR_MESSAGE_USER_2);
}
// conversation_idを取得
const conversationId = getConversationId(lineUserId);
// difyにリクエストを送信
const difyResponse = sendMessageToDify(conversationId, lineUserId, userMessage, imageId);
customLogger.logInfo('difyResponse:' + JSON.stringify(difyResponse));
const difyResponseAnswer = JSON.parse(difyResponse.answer);
const difyResponseMessage = difyResponseAnswer.message;
const difyResponseImage = difyResponseAnswer.image;
const usedConversationId = difyResponse.conversation_id;
if(difyResponseMessage){
replyToLine(replyToken,difyResponseMessage,difyResponseImage);
}else{
throw new Error(ERROR_MESSAGE_SYS_3);
}
// Conversationsシート更新
updateSpreadsheetConversationId(lineUserId,usedConversationId);
// ConversationHistroyシート追記
updateSpreadsheetConversationHistory(lineUserId,conversationId,userMessage,difyResponseMessage)
}else{
replyToLine(replyToken,difyResponseMessage,difyResponseImage);
}
} catch (error) {
customLogger.logError(`doPost関数でエラーが発生:${error.stack}`);
replyToLine(replyToken,ERROR_MESSAGE_USER_1);
}finally{
OutputAllLogToSpreadsheet();
}
}
/**
* Difyアプリに画像をアップロードする
* @param {Blob} imageBlob アップロードする画像のBlobオブジェクト
* @param {string} lineUserId LineのuserId
* @return {Object} チャットサービスからのJSON形式のレスポンス
*/
function uploadImageToDify(imageBlob, lineUserId){
// Boundaryを生成
const boundary = '----WebKitFormBoundary' + new Date().getTime();
const delimiter = '--' + boundary;
const closeDelimiter = '--' + boundary + '--';
// multipart/form-dataの各パートを構築
const formData = [
delimiter + '\r\n' +
'Content-Disposition: form-data; name="user"\r\n\r\n' +
lineUserId + '\r\n' +
delimiter + '\r\n' +
'Content-Disposition: form-data; name="file"; filename="' + lineUserId + '"\r\n' +
'Content-Type: ' + imageBlob.getContentType() + '\r\n\r\n'
];
// Byte配列として結合
const blobByte = Utilities.newBlob(formData.join('')).getBytes()
.concat(imageBlob.getBytes())
.concat(Utilities.newBlob('\r\n' + closeDelimiter + '\r\n').getBytes());
const url = DIFY_URL + '/files/upload';
const options = {
'method': 'POST',
'contentType': 'multipart/form-data; boundary="' + boundary + '"',
'headers': HEADERS,
'payload': blobByte,
'muteHttpExceptions': true,
};
const response = UrlFetchApp.fetch(url, options);
const responseJson = JSON.parse(response);
if(!responseJson.id){
throw new Error(ERROR_MESSAGE_SYS_1);
}
return responseJson;
}
/**
* lineUserIdに紐づくconversation_idを取得する関数
* @param {string} lineUserId lineのuserId
* @return {string} conversation_id
*/
function getConversationId(lineUserId) {
customLogger.logInfo('function getConversationId start:' + `
lineUserId:${lineUserId}
`);
const sheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(SPREAD_SHEET_NAME_CONVERSATIONS);
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] === lineUserId) {
// 既存のconversation_idを更新して返す
sheet.getRange(i + 1, 3).setValue(now);
return data[i][1];
}
}
// conversation_idが存在しない場合は空値を返す
return '';
}
/**
* conversation_idを保存する関数
* @param {string} lineUserId LineのuserId
* @param {string} conversation_id difyのconversation_id
* @return {} なし SPREAD_SHEET
*/
function updateSpreadsheetConversationId(lineUserId,conversation_id) {
customLogger.logInfo('function updateSpreadsheetConversationId start:' + `
lineUserId:${lineUserId}
conversation_id:${conversation_id}
`);
const sheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(SPREAD_SHEET_NAME_CONVERSATIONS);
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] === lineUserId) {
// lineUserIdに紐づく、既存のconversation_idの日付を更新して終了する
sheet.getRange(i + 1, 2).setValue(conversation_id);
sheet.getRange(i + 1, 3).setValue(now);
return;
}
}
// lineUserIdが存在しない場合は新しいレコードを追加する
sheet.appendRow([lineUserId, conversation_id, now]);
return;
}
/**
* Lineの会話履歴を保存する関数
* @param {string} lineUserId LineのuserId
* @param {string} conversation_id difyのconversation_id
* @return {} なし SPREAD_SHEET
*/
function updateSpreadsheetConversationHistory(lineUserId,conversation_id,userRequestMessage,difyResponseMessage) {
customLogger.logInfo('function updateSpreadsheetConversationHistory start:' + `
lineUserId:${lineUserId}
conversation_id:${conversation_id}
userRequestMessage:${userRequestMessage}
difyResponseMessage:${difyResponseMessage}
`);
const sheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(SPREAD_SHEET_NAME_CONVERSATIONS_HISTORY);
// 新しいレコードを最終行の次の行に追加
var newRecord = [lineUserId, conversation_id, userRequestMessage, difyResponseMessage, now];
sheet.appendRow(newRecord);
return;
}
/**
* LINEに返信メッセージを送信します
* @param {string} replyToken -LINEからの返信トークン
* @param {string} message - 送信するメッセージ
*/
function replyToLine(replyToken, message, imageUrl = 'none') {
customLogger.logInfo('function replyToLine start:' + `
replyToken:${replyToken}
message:${message}
imageUrl:${imageUrl}
`);
let payload;
if(imageUrl == 'none'){
payload = {
replyToken: replyToken,
messages: [
{
type: 'text',
text: message
}
]
};
}else{
payload = {
replyToken: replyToken,
messages: [
{
type: 'text',
text: message
}
,{
type: 'image',
originalContentUrl: imageUrl,
previewImageUrl: imageUrl // プレビュー用のURLも同じ
}
]
};
}
const options = {
method: 'post',
contentType: 'application/json',
headers: {
'Authorization': 'Bearer ' + LINE_ACCESS_TOKEN
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
return UrlFetchApp.fetch(LINE_REPLY_URL, options);
} catch (error) {
customLogger.logError("replyToLine:"+ JSON.stringify(error));
}
customLogger.logInfo('replyToLine end:');
}
/**
*グーグルドライブに画像をアップロード
* #param (string) messageId
* #returns (string) Google Drive URL
*/
function getImageUrl(messageId) {
const url = `https://api-data.line.me/v2/bot/message/${messageId}/content`;
const options = {
headers: {
'Authorization': 'Bearer ' + LINE_ACCESS_TOKEN
},
muteHttpExceptions: true
};
try {
const service = getOAuthService();
if (!service.hasAccess()) {
throw new Error("Google Driveへのアクセス権限がありません。認証が必要です");
}
const response = UrlFetchApp.fetch(url, options);
const blob = response.getBlob();
const file = DriveApp.createFile(blob);
file.setName(`LINE_image_${messageId}.jpg`);
const folder = DriveApp.getFolderById(GOOGLE_FOLDER_ID);
file.moveTo(folder);
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
return file.getDownloadUrl();
} catch (error) {
customLogger.logError('画像のアップロードに失敗しました:' + error.toString());
return null;
}
}
/**
* Difyアプリにチャットメッセージを送信する
* @param {string} message ユーザーが送信するチャットメッセージ
* @return {Object} チャットサービスからのJSON形式のレスポンス
*/
function sendMessageToDify(conversationId, lineUserId, userMessage, upload_file_id=''){
customLogger.logInfo('function sendMessageToDify start:' + `
conversationId : ${conversationId}
lineUserId : ${lineUserId}
userMessage : ${userMessage}
imageId : ${upload_file_id}
`);
const url = DIFY_URL + '/chat-messages';
const payload = {
'inputs': {},
'query': userMessage,
'response_mode': 'blocking',
'conversation_id': conversationId,
'user': lineUserId,
}
if(upload_file_id){
payload.files = [
{
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': upload_file_id
}
]
}
const options = {
'method': 'POST',
'contentType': 'application/json',
'headers': HEADERS,
'payload': JSON.stringify(payload),
'muteHttpExceptions': true,
}
const response = UrlFetchApp.fetch(url, options);
const responseJson = JSON.parse(response);
if(responseJson.error){
throw new Error(responseJson.message);
};
return responseJson;
}
GAS(getGoogleImageUrls)
function getGoogleImageUrls() {
// スプレッドシートとフォルダを指定
const SPREAD_SHEET_ID = PropertiesService.getScriptProperties().getProperty('SPREAD_SHEET_ID');
const SPREAD_SHEET_NAME_BONI_IMAGES = 'BoniImages';
const GOOGLE_FOLDER_ID = PropertiesService.getScriptProperties().getProperty('GOOGLE_FOLDER_ID');
const sheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(SPREAD_SHEET_NAME_BONI_IMAGES);
// 既存のID値を取得
const check = sheet.getLastRow();
const existingIds = sheet.getRange(2, 1, sheet.getLastRow(), 1)
.getValues()
.flat()
.filter(id => id !== '');
// フォルダ内の画像ファイルを取得
const folder = DriveApp.getFolderById(GOOGLE_FOLDER_ID);
//test
let id = folder.getId();
let name = folder.getName();
//test
const files = folder.getFilesByType(MimeType.JPEG);
// 新しい画像データを格納する配列
const newImageData = [];
// 画像を走査
while (files.hasNext()) {
const file = files.next();
const imageId = file.getId();
// 重複チェック
if (!existingIds.includes(imageId)) {
const imageUrl = `https://drive.google.com/uc?id=${imageId}`;
const today = new Date();
newImageData.push([
imageId,
imageUrl,
Utilities.formatDate(today, Session.getScriptTimeZone(), 'yyyy/MM/dd')
]);
}
}
// 新しいデータをシートに追加
if (newImageData.length > 0) {
sheet.getRange(sheet.getLastRow() + 1, 1, newImageData.length, 3)
.setValues(newImageData);
}
}
// トリガーを設定する関数(オプション)
function createTrigger() {
ScriptApp.newTrigger('extractGoogleDriveImageUrls')
.timeBased()
.everyHours(24) // 24時間ごとに実行
.create();
}
GAS(common)
var customLogger = {
infoLogs: [],
errorLogs: [],
logInfo: function(message) {
this.infoLogs.push(now + ' INFO : ' + message);
},
logError: function(message) {
this.errorLogs.push(now + ' ERROR : ' + message);
},
getLogInfo: function() {
return this.infoLogs.join('\n');
},
getLogError: function() {
return this.errorLogs.join('\n');
}
};
function getJapanTime() {
// 現在の日時を取得
var now = new Date();
// 日本のタイムゾーン (Asia/Tokyo) を設定してフォーマット
var japanTime = Utilities.formatDate(now, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss');
Logger.log(japanTime);
return japanTime;
}
/**
* log出力関数
*/
function OutputAllLogToSpreadsheet(){
let sheetInfo = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(SPREAD_SHEET_NAME_LOG_INFO);
let sheetError = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(SPREAD_SHEET_NAME_LOG_ERROR);
if (sheetInfo !== null && sheetInfo !== null) {
sheetInfo.appendRow([now, customLogger.getLogInfo()]);
customLogger.getLogError() ? sheetError.appendRow([now, customLogger.getLogError()]) : '';
}else{
sheetError.appendRow([now, 'スプレッドシート上に「errorLog」シートと「infoLog」シートを作成してください']);
}
}
GAS(googleAuth)
/**
* スクリブトの初期設定を確認します
* この関数は手動で実行してください
*/
function checkSetup() {
// if (!DIFY_API_KEY) {
// Logger.log('エラー:Dify APIキーが設定されていません。');
// }
if (!LINE_ACCESS_TOKEN) {
Logger.log('エラー:LINEアクセストークンが設定されていません。');
}
if (!GOOGLE_FOLDER_ID) {
Logger.log('エラー:Google DriveフォルダIDが設定されていません。');
}
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
Logger.log('エラー:OAuth2クライアントIDまたはシークレットが設定されていません。');
}
try {
DriveApp.getFolderById(GOOGLE_FOLDER_ID);
Logger.log('Google DriveフォルダIDは正常に設定されています。');
} catch (error) {
Logger.log('エラー:指定されたGoogle DriveフォルダIDが無効か、アクセスできません。');
Logger.log(error);
}
const service = getOAuthService();
if (service.hasAccess()) {
Logger.log('OAuth2認証は正常に設定されています。');
} else {
Logger.log('警告:OAuth2認証が設定されていないか、アクセストークンが無効です。');
Logger.log('認証URLを開いて認証を行ってください:'+ service.getAuthorizationUrl());
}
Logger.log("設定の確認が完了しました。エラーがない場合は、スクリプトを使用する準備が整っています。");
}
/**
* OAuth2認証サービスを設定します
*/
function getOAuthService() {
return OAuth2.createService('googleDrive')
// クライアントIDとクライアントシークレットを設定
.setClientId(GOOGLE_CLIENT_ID)
.setClientSecret(GOOGLE_CLIENT_SECRET)
// 認証エンドポイントとトークンエンドポイントを設定
.setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/auth')
.setTokenUrl('https://oauth2.googleapis.com/token')
// リダイレクトURLを設定(スクリプトのプロジェクトIDを使います)
.setCallbackFunction('authCallback')
// スコープを設定
.setScope('https://www.googleapis.com/auth/drive.file')
// 承認をリクエストする追加のパラメータ
.setParam('access_type', 'offline')
.setParam('prompt', 'consent')
// キャッシュ用のプロパティストアを設定
.setPropertyStore(PropertiesService.getUserProperties());
}
/**
* 認証コールバック関数
*/
function authCallback(request) {
var service = getOAuthService();
var authorized = service.handleCallback(request);
if (authorized) {
return HtmlService.createHtmlOutput('認証成功!このページを閉じてください。');
} else {
return HtmlService.createHtmlOutput('認証失敗');
}
}
スクリプトプロパティに各キーの値を設定してください
■設定値の概要
DIFY_API_KEY:DIFYのAPIを利用するための認証キー。
GOOGLE_CLIENT_ID:GoogleのOAuth 2.0認証を利用する際に必要なクライアントID
GOOGLE_CLIENT_SECRET:GoogleのOAuth 2.0認証を利用する際に必要なクライアントシークレット
GOOGLE_FOLDER_ID:Google Drive上の特定のフォルダを識別するための一意のID。ユーザーが送信した画像を格納します。
LINE_ACCESS_TOKEN:LINEプラットフォームのAPIを利用するためのアクセストークン。
SPREAD_SHEET_ID:Googleスプレッドシートの特定のシートを識別するための一意のID。
全ての設定が完了したら、OAuth2認証などのチェック、反映作業を実施します。
正常に完了できればOKです。
チャットボットが正常に稼働するはずです。
4.(任意)DifyのRAGを設定する
ここまでの状態だと、チャットボットが正常に動作しますが、
Difyのナレッジ(RAG)が空になっているので、画像付きで返信する機能がありません。
画像付きで返答させたい場合は下記手順を実施してください。
4-1.Googleドライブに画像のアップロードする
GOOGLE_FOLDER_IDに設定したグーグルドライブに、任意の画像をアップロードしてください。
(jpegで動確してますが、おそらく他の拡張子でも可能です)
このフォルダは、チャットボットに画像を送信した場合に保存されるフォルダと同じです。
4-2.画像データのURLと検索値を整理する
lineで受け取った画像データや手動で追加したデータに対して、GASの「getGoogleImageUrls」を実行することで、URLをDifyのRAGとして取り込むための形式で整えられます。
この後に、該当シートのD列以降に任意のタグ付け(チャットボットに対して入力されたワードに対して、ヒットさせたいキーワードを設定する)を手動で行い、CSVデータとして出力してください。
4-3.Difyのナレッジを作成する
出力したCSVファイルをDifyに登録します。
ナレッジの設定値ですが、返す画像を選定する際の検索機能への影響が大きいので、
少なくともベクトル検索、理想はハイブリッド検索にして下さい。
ナレッジの作成が完了すれば、ワークフローを編集して、作成したナレッジを設定してください。
Tips
プロンプトはclaude.aiに依頼すると楽
結構高いレベルのプロンプトが出力されるので、プロンプトに自信がなかったり面倒な場合は依頼すればよいです。
最近、添削してくれる機能も追加されたようです。
過去の会話履歴に引っ張られる
例えば、ある改修のタイミングで、レスポンスの形式を変えたりとか、
チャットボットしての振る舞いを変えたいことがあると思います。
その場合、以前の会話履歴が残っていると、うまく変更内容が反映されないことが多々あるので、
リリース時には、一度会話履歴をまとめてリセットするのが無難だと思いました。
また、一定のタイミングで会話履歴をリセットするなど制御してあげる必要がありそうです。
振り返り
画像とテキストをまとめて返すのには、一苦労しました。
現状、レスポンスのパラメーターが無理やりjson形式にしており、
LLMの生成する回答次第では、うまくjson形式にならず、エラーになってしまうので改善する必要があります。
また、複数の環境を経由するので、レスポンス時間に課題感が強くあります。
これが原因で、送信できていないと勘違いするユーザーも発生しそうなので、送信の制御をするか
環境を移管したりまとめたりして、パフォーマンスの向上が必要です。
最後に
ここまで勢いで開発と記事作成を進めてきましたので、内容が粗削りで文章も拙い部分があるかと思います。
もしご不明な点や改善のご提案がございましたら、ぜひお知らせいただけますと幸いです。
Discussion
はじめまして
楽しく読ませていただきました。
もう可能であれば教えて頂きたいことがあります。
LINE_ACCESS_TOKEN はどのように取得するのでしょうか。
上記は以下の記載があります。ー
Messaging APIチャネルでのみ発行できます。LINE DevelopersコンソールのMessaging APIチャネルにある[Messaging API設定]タブより、いつでも発行できます。なお、トークンはいつでも取り消すことができます。
しかしMessaging APIタブにはトークン発行などありません。
ご教示いただくことは可能でしょうか
コメントありがとうございます!
こちらから作成した任意のアカウントにログインいただき、プロバイダー > {作成したチャネル} > Messaging API設定 まで進めると、ページ下部に「チャネルアクセストークン」という項目が表示されないでしょうか?
おそらく、はじめは"新規発行"などのボタンが表示されているかと思います。
ご返信ありがとうございます。
もう少し詳しく教えていただけますでしょうか。
質問が多く恐れ入ります。
どうぞよろしくお願いします。
「4.(任意)DifyのRAGを設定する」に手順を追記しました。こちらご確認くださいませ。
jpegで確認しましたが、おそらく他の拡張子でも大丈夫だと思います。
詳細は、LINEのAPIのドキュメントをご確認ください。
はいそうです。
D列以降にヒットさせたい任意のキーワードを設定していただければと思います。
はいそうです。dify側に取り込むために整形したデータを出力します。
わからない点あれば、続けてご質問ください!