🐕

Line×GAS×Dify(RAG含む)で画像付きで返事できるchatbotを作った

2024/12/26に公開
4

はじめに

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」を使用するので、そのためにアカウント登録をします。
本記事では詳細は割愛しますが、公式の手順を参照すれば作成できるはず。
https://developers.line.biz/ja/docs/messaging-api/getting-started/

後述で作成する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付きのスプレットシートを共有します。)
https://docs.google.com/spreadsheets/d/1E012M6p-73Wkgr8HPbjKDrIuPkYwWkHyPfyhUKz0Tbo/edit?usp=sharing

(コードの内容は時間があるときに解説記事を追加で書きます)

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に依頼すると楽

結構高いレベルのプロンプトが出力されるので、プロンプトに自信がなかったり面倒な場合は依頼すればよいです。
https://claude.ai/new

最近、添削してくれる機能も追加されたようです。
https://zenn.dev/acntechjp/articles/c9e62bb3d6bd17

過去の会話履歴に引っ張られる

例えば、ある改修のタイミングで、レスポンスの形式を変えたりとか、
チャットボットしての振る舞いを変えたいことがあると思います。
その場合、以前の会話履歴が残っていると、うまく変更内容が反映されないことが多々あるので、
リリース時には、一度会話履歴をまとめてリセットするのが無難だと思いました。
また、一定のタイミングで会話履歴をリセットするなど制御してあげる必要がありそうです。

振り返り

画像とテキストをまとめて返すのには、一苦労しました。
現状、レスポンスのパラメーターが無理やりjson形式にしており、
LLMの生成する回答次第では、うまくjson形式にならず、エラーになってしまうので改善する必要があります。

また、複数の環境を経由するので、レスポンス時間に課題感が強くあります。
これが原因で、送信できていないと勘違いするユーザーも発生しそうなので、送信の制御をするか
環境を移管したりまとめたりして、パフォーマンスの向上が必要です。

最後に

ここまで勢いで開発と記事作成を進めてきましたので、内容が粗削りで文章も拙い部分があるかと思います。
もしご不明な点や改善のご提案がございましたら、ぜひお知らせいただけますと幸いです。

Accenture Japan (有志)

Discussion

TOMOAKI KADONOTOMOAKI KADONO

はじめまして
楽しく読ませていただきました。

もう可能であれば教えて頂きたいことがあります。
LINE_ACCESS_TOKEN はどのように取得するのでしょうか。

https://developers.line.biz/ja/docs/basics/channel-access-token/#short-lived-channel-access-token
上記は以下の記載があります。

Messaging APIチャネルでのみ発行できます。LINE DevelopersコンソールのMessaging APIチャネルにある[Messaging API設定]タブより、いつでも発行できます。なお、トークンはいつでも取り消すことができます。

しかしMessaging APIタブにはトークン発行などありません。

ご教示いただくことは可能でしょうか

Shinya.SawakiShinya.Sawaki

コメントありがとうございます!

https://developers.line.biz/console/
こちらから作成した任意のアカウントにログインいただき、
プロバイダー > {作成したチャネル} > Messaging API設定 まで進めると、ページ下部に「チャネルアクセストークン」という項目が表示されないでしょうか?

おそらく、はじめは"新規発行"などのボタンが表示されているかと思います。

TOMOAKI KADONOTOMOAKI KADONO

ご返信ありがとうございます。

Googleドライブに画像のアップロードとスプレットシートでタグ付けを行って、
CSVファイルとして吐き出して、difyに取り込んでください。

もう少し詳しく教えていただけますでしょうか。

Googleドライブに画像のアップロード
これは写真であれは何でもよいでしょうか。

スプレットシートでタグ付けを行って、
XXX_Line×Dify連携_配布用_20241220のスプレットシートのことだと思います。
タグつけとはどのように行うのでしょうか。

CSVファイルとして吐き出して
スプレッドシートをCSVに出力する機能のことでしょうか。

質問が多く恐れ入ります。
どうぞよろしくお願いします。

Shinya.SawakiShinya.Sawaki

Googleドライブに画像のアップロードとスプレットシートでタグ付けを行って、
CSVファイルとして吐き出して、difyに取り込んでください。

「4.(任意)DifyのRAGを設定する」に手順を追記しました。こちらご確認くださいませ。

Googleドライブに画像のアップロード
これは写真であれは何でもよいでしょうか。

jpegで確認しましたが、おそらく他の拡張子でも大丈夫だと思います。
詳細は、LINEのAPIのドキュメントをご確認ください。

CSVファイルとして吐き出して
スプレッドシートをCSVに出力する機能のことでしょうか

はいそうです。

スプレットシートでタグ付けを行って、
XXX_Line×Dify連携_配布用_20241220のスプレットシートのことだと思います。
タグつけとはどのように行うのでしょうか。

D列以降にヒットさせたい任意のキーワードを設定していただければと思います。

CSVファイルとして吐き出して
スプレッドシートをCSVに出力する機能のことでしょうか。

はいそうです。dify側に取り込むために整形したデータを出力します。

わからない点あれば、続けてご質問ください!