📰

【Dify × Slack】話題ニュースをAIで取得して朝9時に通知してくれる機能作ってみた

2024/08/08に公開

はじめに

相変わらずDify熱が下がらず、日々色々なPoCをやっています。
ところでみなさんニュースは毎日見ていますか??ニュースアプリを開いてニュースを閲覧。。余計な興味ないニュースも入ってきて自分は正直疲れてしまいます。
というわけで、AIで関心があるニュースのみ取得して、勝手にSlackに送ってくれる機能を作ってみました。

作ったもの

朝9時に、Slackの自分のチャンネルにビジネス関連の最新話題ニュースを3つ取得してくる。

アーキテクチャー

現在、Slack上には、Difyと連携させる機能がありません。
なので、今回は、GCP(Google Cloud Platform)ベースでSlackAPIを連携させました。DifyとSlackAPIで作ったものを、Cloud Functionsでファンクション化し、Cloud Schedulerを使って、毎朝9時に定期通知するように設計します。

手順

1. Difyでワークフローを作成 (2024/8/15 更新)

Difyからワークフローでアプリを作ります。

  1. HTTPリクエストで、yahooニュースのRSSから経済に関するニュースを取得。
  2. 1で取得したきた結果から上から3つニュースを取得するように加工
  3. 取得したきた3つのニュース情報をループ処理で要約
  4. 3の出力内容を微調整して加工
  5. Slackのチャンネルに通知

こちらが作成したDSLです。参考にどうぞ。

DSL
app:
  description: 最新のニュースをyahooニュースから取得するように改良。
  icon: 🤖
  icon_background: '#FFEAD5'
  mode: workflow
  name: Newアプリ改良版
kind: app
version: 0.1.1
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      image:
        enabled: false
        number_limits: 3
        transfer_methods:
        - local_file
        - remote_url
    opening_statement: ''
    retriever_resource:
      enabled: false
    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: start
        targetType: http-request
      id: 1718076250420-source-1718076269129-target
      selected: false
      source: '1718076250420'
      sourceHandle: source
      target: '1718076269129'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: http-request
        targetType: code
      id: 1718076269129-source-17235252683910-target
      selected: false
      source: '1718076269129'
      sourceHandle: source
      target: '17235252683910'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: code
        targetType: iteration
      id: 17235252683910-source-17235282227240-target
      selected: false
      source: '17235252683910'
      sourceHandle: source
      target: '17235282227240'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: true
        iteration_id: '17235282227240'
        sourceType: llm
        targetType: tool
      id: 1723529086478-source-1723530096605-target
      selected: false
      source: '1723529086478'
      sourceHandle: source
      target: '1723530096605'
      targetHandle: target
      type: custom
      zIndex: 1002
    - data:
        isInIteration: true
        iteration_id: '17235282227240'
        sourceType: tool
        targetType: llm
      id: 1723530096605-source-1723530564634-target
      selected: false
      source: '1723530096605'
      sourceHandle: source
      target: '1723530564634'
      targetHandle: target
      type: custom
      zIndex: 1002
    - data:
        isInIteration: true
        iteration_id: '17235282227240'
        sourceType: llm
        targetType: llm
      id: 1723530564634-source-1723532664762-target
      source: '1723530564634'
      sourceHandle: source
      target: '1723532664762'
      targetHandle: target
      type: custom
      zIndex: 1002
    - data:
        isInIteration: false
        sourceType: iteration
        targetType: code
      id: 17235282227240-source-1723533052035-target
      source: '17235282227240'
      sourceHandle: source
      target: '1723533052035'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: code
        targetType: tool
      id: 1723533052035-source-1723544419745-target
      source: '1723533052035'
      sourceHandle: source
      target: '1723544419745'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: tool
        targetType: end
      id: 1723544419745-source-1723544409743-target
      source: '1723544419745'
      sourceHandle: source
      target: '1723544409743'
      targetHandle: target
      type: custom
      zIndex: 0
    nodes:
    - data:
        desc: ''
        selected: false
        title: 開始
        type: start
        variables: []
      height: 53
      id: '1718076250420'
      position:
        x: 28.42253885859111
        y: 328.838586091834
      positionAbsolute:
        x: 28.42253885859111
        y: 328.838586091834
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        authorization:
          config: null
          type: no-auth
        body:
          data: ''
          type: none
        desc: ''
        headers: ''
        method: get
        params: ''
        selected: false
        timeout:
          max_connect_timeout: 0
          max_read_timeout: 0
          max_write_timeout: 0
        title: HTTPリクエスト
        type: http-request
        url: https://news.yahoo.co.jp/rss/topics/business.xml
        variables: []
      height: 105
      id: '1718076269129'
      position:
        x: 326.2615840057931
        y: 328.838586091834
      positionAbsolute:
        x: 326.2615840057931
        y: 328.838586091834
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        code: "import re\n\ndef main(content: str) -> dict:\n    # <channel>タグ内の<item>タグを取得するための正規表現パターン\n\
          \    item_pattern = r'<item>(.*?)</item>'\n    items = re.findall(item_pattern,\
          \ content, re.DOTALL)\n\n    extracted_items = []\n    for item in items[:3]:\
          \  # 最初の3つの<item>タグを処理\n        title_match = re.search(r'<title>(.*?)</title>',\
          \ item)\n        link_match = re.search(r'<link>(.*?)</link>', item)\n \
          \       description_match = re.search(r'<description>(.*?)</description>',\
          \ item)\n        \n        title = title_match.group(1) if title_match else\
          \ \"No Title\"\n        link = link_match.group(1) if link_match else \"\
          No Link\"\n        description = description_match.group(1) if description_match\
          \ else \"No Description\"\n        \n        extracted_items.append({\n\
          \            \"title\": title,\n            \"link\": link,\n          \
          \  \"description\": description\n        })\n\n    return {\n        \"\
          items\": extracted_items\n    }\n\n# テスト実行\ncontent = \"\"\"<?xml version=\"\
          1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<rss version=\"2.0\">\n \
          \ <channel>\n    <language>ja</language>\n    <copyright>© LY Corporation</copyright>\n\
          \    <pubDate>Tue, 13 Aug 2024 02:57:07 GMT</pubDate>\n    <title>Yahoo!ニュース・トピックス\
          \ - 経済</title>\n    <link>https://news.yahoo.co.jp/topics/business?source=rss</link>\n\
          \    <description>Yahoo! JAPANのニュース・トピックスで取り上げている最新の見出しを提供しています。</description>\n\
          \    <item>\n      <title>日経平均、一時1000円超の上昇</title>\n      <link>https://news.yahoo.co.jp/pickup/6510536?source=rss</link>\n\
          \      <pubDate>Tue, 13 Aug 2024 01:11:04 GMT</pubDate>\n      <comments>https://news.yahoo.co.jp/articles/986a9041d5f46a9453aa5d36fdb7ec58e099f985/comments</comments>\n\
          \    </item>\n    <item>\n      <title>5日の東京株急落「恐怖」が影響か</title>\n      <link>https://news.yahoo.co.jp/pickup/6510527?source=rss</link>\n\
          \      <pubDate>Tue, 13 Aug 2024 00:14:34 GMT</pubDate>\n      <comments>https://news.yahoo.co.jp/articles/3616b637bb7c6080fe9ac324545198a3de31c759/comments</comments>\n\
          \    </item>\n    <item>\n      <title>NY株が3日ぶり反落 140ドル安</title>\n     \
          \ <link>https://news.yahoo.co.jp/pickup/6510521?source=rss</link>\n    \
          \  <pubDate>Mon, 12 Aug 2024 23:17:20 GMT</pubDate>\n      <comments>https://news.yahoo.co.jp/articles/75e7f81e32fe32ec451f6b21adec01c347185da3/comments</comments>\n\
          \    </item>\n  </channel>\n</rss>\"\"\"\n\nresult = main(content)\nfor\
          \ item in result['items']:\n    print(item)\n"
        code_language: python3
        dependencies: []
        desc: ''
        outputs:
          items:
            children: null
            type: array[object]
        selected: false
        title: XMLを変換
        type: code
        variables:
        - value_selector:
          - '1718076269129'
          - body
          variable: content
      height: 53
      id: '17235252683910'
      position:
        x: 660.8349467343194
        y: 328.838586091834
      positionAbsolute:
        x: 660.8349467343194
        y: 328.838586091834
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        height: 402
        iterator_selector:
        - '17235252683910'
        - items
        output_selector:
        - '1723532664762'
        - text
        output_type: array[string]
        selected: false
        startNodeType: llm
        start_node_id: '1723529086478'
        title: 記事要約ループ処理
        type: iteration
        width: 1588
      height: 402
      id: '17235282227240'
      position:
        x: 1012.8625966689151
        y: 328.838586091834
      positionAbsolute:
        x: 1012.8625966689151
        y: 328.838586091834
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 1588
      zIndex: 1
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        isInIteration: true
        isIterationStart: true
        iteration_id: '17235282227240'
        model:
          completion_params:
            temperature: 0.7
          mode: chat
          name: gpt-4
          provider: openai
        prompt_template:
        - id: 4e081ddd-a407-4fba-a8b2-55c1f1954fce
          role: system
          text: '{{#17235282227240.item#}}内に含まれているlinkを出力して。'
        selected: false
        title: URL抽出
        type: llm
        variables: []
        vision:
          enabled: false
      extent: parent
      height: 97
      id: '1723529086478'
      parentId: '17235282227240'
      position:
        x: 117
        y: 85
      positionAbsolute:
        x: 1129.862596668915
        y: 413.838586091834
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
      zIndex: 1001
    - data:
        desc: ''
        isInIteration: true
        iteration_id: '17235282227240'
        provider_id: jina
        provider_name: jina
        provider_type: builtin
        selected: false
        title: JinaReader
        tool_configurations:
          gather_all_images_at_the_end: 0
          gather_all_links_at_the_end: 0
          image_caption: 0
          max_retries: 3
          no_cache: 0
          proxy_server: null
          summary: 0
          target_selector: null
          wait_for_selector: null
        tool_label: JinaReader
        tool_name: jina_reader
        tool_parameters:
          request_params:
            type: mixed
            value: ''
          url:
            type: mixed
            value: '{{#1723529086478.text#}}'
        type: tool
      extent: parent
      height: 297
      id: '1723530096605'
      parentId: '17235282227240'
      position:
        x: 420
        y: 85
      positionAbsolute:
        x: 1432.862596668915
        y: 413.838586091834
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
      zIndex: 1002
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        isInIteration: true
        iteration_id: '17235282227240'
        model:
          completion_params:
            temperature: 0.7
          mode: chat
          name: gemini-1.5-flash-latest
          provider: google
        prompt_template:
        - id: 51c6fee7-4e70-4064-adbb-b3d7394f38df
          role: system
          text: あなたは優秀な記者です。{{#1723530096605.text#}}を50字以内に要約して。
        - id: 36e43ff3-73e3-4b90-a40a-194ebab6ec8f
          role: assistant
          text: 日本語
        selected: false
        title: 記事要約
        type: llm
        variables: []
        vision:
          configs:
            detail: high
          enabled: true
      extent: parent
      height: 97
      id: '1723530564634'
      parentId: '17235282227240'
      position:
        x: 739.1100297557973
        y: 86.79000330619976
      positionAbsolute:
        x: 1751.9726264247124
        y: 415.62858939803374
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
      zIndex: 1002
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        isInIteration: true
        iteration_id: '17235282227240'
        model:
          completion_params:
            temperature: 0.7
          mode: chat
          name: gpt-4
          provider: openai
        prompt_template:
        - id: 099cee5c-8286-4fc7-81c4-b7dbd6ce8334
          role: system
          text: "あなたは、エンジニアです。#出力形式に沿ってニュースを{{#17235282227240.index#}}分出力してください。\n\
            承知しましたなどの会話は入りません。ただ以下の #出力形式と #出力条件に沿って、出力してください。\n\n\n\n#出力形式:\n\n{{変数A}\
            \ . {{変数B}}\n \n要約:{{{{#1723530564634.text#}}}}\n \nURL: {{{{#1723529086478.text#}}}}\n\
            \n#変数\nA = {{#17235282227240.index#}} + 1\nB = {{#17235282227240.item#}}のtitle\n\
            \n#条件:\n・{{{#17235282227240.item#}}のtitle}}の部分には、クオーテーションマークは要りません。除いて出力してください。\n"
        selected: false
        title: 出力内容をAIで加工
        type: llm
        variables: []
        vision:
          enabled: false
      extent: parent
      height: 97
      id: '1723532664762'
      parentId: '17235282227240'
      position:
        x: 1095.024981036534
        y: 85
      positionAbsolute:
        x: 2107.887577705449
        y: 413.838586091834
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
      zIndex: 1002
    - data:
        code: "def main(output: list) -> dict:\n    # 最初の行に「今日の話題ニュースです!\U0001F4E3\
          」を追加\n    result_string = \"今日の話題ニュースです!\U0001F4E3\\n\\n\" + \"\\n\\n\"\
          .join(output)\n\n    return {\n        \"result\": result_string\n    }\n\
          # def main(output: list) -> dict:\n#     modified_output = []\n\n#     for\
          \ item in output:\n#         modified_output.append(f\"今日の話題ニュースです!\U0001F4E3\
          \\n{item}\")\n\n#     # リストを改行で結合して1つの文字列にする\n#     result_string = \"\\\
          n\\n\".join(modified_output)\n\n#     return {\n#         \"result\": result_string\n\
          #     }\n\n# # テスト用データ\n# output = [\n#     \"\\\"シャトレーゼ創業 斉藤寛氏が死去\\\"\\\
          n \\n要約:\\\"シャトレーゼ創業者の斉藤寛氏が8月10日、心不全のため死去。享年90歳。\\\"\\n \\nURL: \\\"https://news.yahoo.co.jp/pickup/6510572?source=rss\\\
          \"\",\n#     \"\\\"日経平均 終値3万6000円台に戻す\\\"\\n \\n要約:日経平均株価は1207円高の3万6232円51銭で終値、7営業日ぶりに3万6000円台を回復しました。\
          \ \\n \\nURL:https://news.yahoo.co.jp/pickup/6510571?source=rss\",\n#  \
          \   \"5日の東京株急落「恐怖」が影響か\\n \\n要約:8月5日、東京株式市場は過去最大の下げ幅を記録。日経平均株価は4451円安の3万1458円に。米経済悪化懸念が背景とみられる。\
          \ \\n \\nURL:https://news.yahoo.co.jp/pickup/6510527?source=rss\"\n# ]\n\
          \n# result = main(output)\n# print(result[\"result\"])\n\n"
        code_language: python3
        desc: ''
        outputs:
          result:
            children: null
            type: string
        selected: false
        title: 微調整
        type: code
        variables:
        - value_selector:
          - '17235282227240'
          - output
          variable: output
      height: 53
      id: '1723533052035'
      position:
        x: 2820.1861719512117
        y: 373.01962648073305
      positionAbsolute:
        x: 2820.1861719512117
        y: 373.01962648073305
      selected: true
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        outputs: []
        selected: false
        title: 終了
        type: end
      height: 53
      id: '1723544409743'
      position:
        x: 3448.2130080489633
        y: 373.01962648073305
      positionAbsolute:
        x: 3448.2130080489633
        y: 373.01962648073305
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        desc: ''
        provider_id: slack
        provider_name: slack
        provider_type: builtin
        selected: false
        title: Incoming Webhook to send message
        tool_configurations:
          webhook_url: https://hooks.slack.com/services/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
        tool_label: Incoming Webhook to send message
        tool_name: slack_webhook
        tool_parameters:
          content:
            type: mixed
            value: '{{#1723533052035.result#}}'
        type: tool
      height: 89
      id: '1723544419745'
      position:
        x: 3137.383322223892
        y: 373.01962648073305
      positionAbsolute:
        x: 3137.383322223892
        y: 373.01962648073305
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    viewport:
      x: 33.051700138396654
      y: 172.7325083368391
      zoom: 0.6339300371652073

2. Slackと連携

Slack側で、WebhookとなるURLを発行します。ここでどのチャンネルに投稿させられるか設定できます。以下の記事が参考になりましたので、引用させていただきます。
https://zenn.dev/hotaka_noda/articles/4a6f0ccee73a18

そのあと、Difyに戻り、Slack側で発行したWebhook URLを SlackノードのSLACK INCOMING WEB HOOK URLに入れます。
あと、入力変数には、LLMの結果を出力させるようにしてください。

ここまで設定し、実際にDify上でワークフローを手動で動かしてみると以下のように指定したチャンネルに通知が来ることがわかります。

3. DifyでAPIキー発行

左の概要ページに行き、以下のAPIキーをクリックする。

「新しいシークレットキーを作成」をクリックし、APIキーを発行しましょう。キーは後ほどGCP上で必要となるのでコピーしておいてください。

4. GCPと連携

まず、GCP上でプロジェクトを作ります。アカウントがない方は、以下から作成してください。
https://cloud.google.com/?hl=ja

4-1. APIキーを保存

GCP Consoleで「APIとサービス」をクリックし、ダッシュボードに移動し、「Secret Manager API」を探して、「有効にする」をクリックする。

GCPコンソールから、「セキュリティ」を選択し、そこから「Secret Manager」に移動し、「シークレットを作成」をクリックする。

以下の入力して、「シークレット作成」を押下する。

  • 名前
    • dify-api-key(好きな名前でOKです)
  • シークレットの値
    • コピーしたDifyのAPI key

4-2.Cloud Fuctionを作成

GCP Consoleで「Cloud Functions」をクリックし、ダッシュボードに移動し、「ファンクションの作成」をクリックし、新規作成しましょう。

以下の設定でファンクションを作成します。

  • 環境
    • 第1世代
  • 関数名
    • 任意の関数名
  • リージョン
    • asia-northeast1 (場所によって変えてください)
  • トリガーのタイプ
    • HTTP
  • 認証
    • 認証が必要
    • HTTPSが必須にチェック

そのあと、以下のコードを書きます。

index.js


const {SecretManagerServiceClient} = require('@google-cloud/secret-manager');
const axios = require('axios');

const secretManager = new SecretManagerServiceClient();

async function getSecret(name) {
  const [version] = await secretManager.accessSecretVersion({name: name});
  return version.payload.data.toString();
}

exports.executeDifyApi = async (req, res) => {
  try {
    // Secret Manager からDify API Keyを取得
    const apiKey = await getSecret('projects/YOUR_GCP_PROJECT_NAME/secrets/dify-api-key/versions/latest');
    // Dify APIを呼び出し
    const response = await axios.post('https://api.dify.ai/v1/workflows/run', {
      inputs: {}, // Dify APIに必要な入力パラメータ
      response_mode : "blocking",
      user: "test",
    }, {
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      }
    });

    console.log('Dify API response:', response.data);
    res.status(200).send('Dify API call successful');
  } catch (error) {
    console.error('Error calling Dify API:', error);
    res.status(500).send('Dify API call failed');
  }
};

package.js

{
  "dependencies": {
    "@google-cloud/secret-manager": "^4.0.0",
    "axios": "^0.21.1"
  }
}

書いた後、テストしてみて、問題なく呼び出せているようでしたら、そのままデプロイしてください。

4-3.アクセス権の付与

GCP Consoleから「IAMと管理」を選択し、「アクセス権を付与」をクリックする。これがないと、Cloud Function呼び出し時に、403エラーが出ます。

以下の入力し、保存をクリック。

  • 新しいプリンシパル
    • サービスカウントを入力する
  • ロールを選択
    • Secret Managerのシークレットアクセサー

サービスアカウントは、Cloud Functionsの詳細から確認できます。

また、Cloud Functionsに戻り、「権限」をクリックし、「プリンシパルを追加」をクリック。

以下の入力し、保存をクリック。

  • 新しいプリンシパル
    • 先ほど入力したサービスカウント
  • ロールを選択
    • Cloud Functions起動元

4-4.Cloud Schedulerを作成

GCP Console で Cloud Scheduler に移動し、「ジョブを作成」 をクリックする。

以下の設定を行います:

  • 名前
    • 任意の名前
  • リージョン
    • Cloud Functions設定時と同じもの
  • 説明
    • 入れなくてもOK!
  • 頻度
    • cron 形式で指定 (例: 毎日午前9時に実行する場合 0 9 * * *)。詳細はこちらを参照してみてください。
  • ターゲットタイプ
    • HTTP
  • URL
    • 作成したCloud FunctionsのトリガーとなるURL
  • HTTPヘッダー
    • 設定しなくてOK
  • Authヘッダー
    • OIDCトークン
  • サービスアカウント
    • 作成したCloud Functionsのサービスアカウントを選択

その後のオプションの設定はデフォルトの設定値で大丈夫です。もし気になる場合は、ご自身で好きな値にしてください!

ちなみに、トリガーURLはCloud Functionsのトリガーから確認できます。

これで設定は完了です!
実際に動かしてみると、朝9時に設定したチャンネル(今回はSlack上の自分のチャンネル)に、きちんと通知がくるようになりました!!

まとめ

今回は、GCPとSlackとDifyを組み合わせて、Slackに通知する仕組みを作りました。AIのロジックの部分をDifyでサクッと作れるのはいいですね!
ますますDifyの将来性に期待です!!

株式会社QED AIブログ

Discussion