👻

SlackとAmazon Bedrock Agentで作るチャットボットハンズオン

2025/03/11に公開

1. はじめに

システムゼウスの杉山です。
このハンズオンでは、SlackアプリとAmazon Bedrock Agentを組み合わせて、チャットボットを作成する方法を解説します。最後まで読めばSlackのダイレクトメッセージから質問を投げかけると、Amazon Bedrock Agentが回答してくれるチャットボットが完成します。
使用するAgentは以下の記事で作成したものをそのまま流用します

以下参考記事↓
https://zenn.dev/medai1107/articles/bedrock-kb-kaitou-seisei-kadai

また、次の記事では今回使用したLambdaを利用してストリーミングレスポンスの実装まで行います。

前提条件

  • AWSアカウント
  • Slack ワークスペースの管理者権限

システム構成

3.Lambdaの作成

3.1 Lambda新規作成

最初にSlackAppから実行されるLambdaの作成を行っていきます。
実行ロールはデフォルトのままで問題ありません。
関数URLを有効化して認証タイプはNONEで作成します。

設定したら右下の「関数を作成」をクリック

3.2 Lambdaの設定

まずは設定に移動してタイムアウトを1分まで延長して保存。

3.3 権限の付与

作成したLambdaからエージェントを実行するために、権限の付与を行っていきます。
Lambdaを作成した際に自動で作成されたロールをIAMで選択し、
許可を追加からポリシーをアタッチを選択

一覧の中から「AmazonBedrockFullAccess」を選択して右下の許可を追加を選択してください。

3.4 SlackAppイベント用処理の追加

後述のEvent Subscriptions用の処理を先に記述しておきましょう。

  1. Lambda関数のコードエディタを開き、以下のコードを貼り付けます
export const handler = async(event) => {
    // リクエストボディのパース
    const body = JSON.parse(event.body);
    
    // Slack からの URL 検証リクエストの場合
    if (body.type === 'url_verification') {
        return {
            statusCode: 200,
            body: JSON.stringify({
                challenge: body.challenge
            })
        };
    }
    
    // 検証以外のイベントの場合は200を返す
    // (この後のハンズオンで実装を追加します)
    return {
        statusCode: 200,
        body: JSON.stringify({
            message: 'Success'
        })
    };
};
  1. 右上の「Deploy」ボタンをクリックしてコードを保存します。

4.SlackAppの作成

4.1 アプリ新規作成

  1. Slack API にアクセス
  2. 「Create New App」をクリック
  3. 「From scratch」を選択
  4. アプリ名と作成するワークスペースを選択

4.2 権限の設定

左メニューから「OAuth & Permissions」を選択し、必要な権限を追加していきます。
スクロールして「Scopes」セクションまで移動し、「Bot Token Scopes」で以下の権限を追加します

  • chat:write: ボットがメッセージを送信するために必要
  • channels:history: パブリックチャンネルのメッセージを読み取るために必要

4.3 App Manifestの設定

DMでのやり取りを行うためにApp Manifestの変更を行っていきます。

{
    "display_information": {
        "name": "TEST_APP"
    },
    "features": {
        "app_home": {
            "home_tab_enabled": false,
            "messages_tab_enabled": true,
            "messages_tab_read_only_enabled": false
        },
        "bot_user": {
            "display_name": "TEST_APP",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "chat:write",
                "channels:history",
                "im:history"
            ]
        }
    },
    "settings": {
        "event_subscriptions": {
            "request_url": "https://7ykqq6mzpfgvahkqkrkm5qi2eu0zikic.lambda-url.ap-northeast-1.on.aws/",
            "bot_events": [
                "message.im"
            ]
        },
        "org_deploy_enabled": false,
        "socket_mode_enabled": false,
        "token_rotation_enabled": false
    }
}

featuresの値を以下の画像になるように設定してください。

変更したら右上のSaveChangesをクリック

4.4 イベントの設定

  1. 左メニューから「Event Subscriptions」を選択します

  2. 「Enable Events」をオンにします

  3. Request URLに先ほど作成したLambda関数のURLを入力します
    リクエストが成功すると以下の画像のようになります。
    合わせてSubscribe to bot eventsの「message.im」を追加
    ※ Lambda関数のURLは関数の概要から確認できます

  4. 右下の「Save Changes」ボタンをクリックして保存します。

4.5 Basic Information

  1. 左メニューから「Basic Information」を選択します。
  2. 「App Credentials」セクションにある「Signing Secret」をメモしておきます
    ※ 後ほどLambdaの環境変数で使用します

4.6 アプリのインストール

  1. 左メニューから「Install App」を選択します
  2. 「Install to Workspace」をクリックします
  3. 認証画面が表示されるので「許可する」をクリックします
    インストール後に表示される「Bot User OAuth Token」は後ほど使用するのでメモしておいてください。

5.Lambda関数の実装

5.1 環境変数の設定

Lambdaの環境変数を設定します。
設定タブから環境変数を選択し、以下の変数を追加

  • SLACK_BOT_TOKEN: 先ほどメモしたBot User OAuth Token
  • SLACK_SIGNING_SECRET: Basic InformationにあるSigning Secret
  • BEDROCK_AGENT_ID: 作成したBedrockエージェントのID
  • BEDROCK_AGENT_ALIAS_ID: エージェントのエイリアスID

5.2 エディタから処理を書き換え

以下のソースをコピーしてエディタに張り付けてください。

ソースコード
import crypto from 'crypto';

import {  BedrockAgentRuntimeClient, InvokeAgentCommand } from '@aws-sdk/client-bedrock-agent-runtime';

/*
 * Slackの公式ドキュメントに基づく署名検証
 * 参考: https://api.slack.com/authentication/verifying-requests-from-slack
 */
function verifyRequestSignature(event) {
  const signature = event.headers['x-slack-signature'];
  const timestamp = Number(event.headers['x-slack-request-timestamp']);

  const now = Math.floor(Date.now() / 1000);
  if(!timestamp || Math.abs(now - timestamp) > 300) {
    throw new Error('Request is too old');
  }

  const signatureBaseString = `v0:${timestamp}:${event.body}`;
  const slackSigningSecret = process.env.SLACK_SIGNING_SECRET;
  if(!slackSigningSecret) {
    throw new Error('SLACK_SIGNING_SECRET is not defined');
  }
  const mySignature = `v0=${
    crypto.createHmac('sha256', slackSigningSecret)
      .update(signatureBaseString)
      .digest('hex')}`;

  if(mySignature !== signature) {
    throw new Error('Invalid signature');
  }
}

// エージェントの実行
async function invokeAgent(agentParams) {
  const client = new BedrockAgentRuntimeClient({ region: 'ap-northeast-1' });
  const command = new InvokeAgentCommand(agentParams);

  try {
    const agentResponse = await client.send(command);
    if(agentResponse.$metadata.httpStatusCode !== 200) {
      console.error('エージェントの呼び出しに失敗');
      throw new Error(`Unexpected HTTP status code: ${agentResponse.$metadata.httpStatusCode}`);
    }
    console.log('Agent response:', agentResponse);
    let result = '';

    if(agentResponse.completion) {
      const decoder = new TextDecoder();
      for await (const itr of agentResponse.completion) {
        if(itr.chunk?.bytes) {
          result += decoder.decode(itr.chunk.bytes, { stream: true });
        }
      }
    } else {
      throw new Error('No completion in agent response');
    }

    return result.trim();
  } catch (error) {
    console.error('Error invoking Bedrock agent:', error);
    if(error instanceof Error) {
      throw new Error(`Unexpected error occurred: ${error.message}`);
    } else {
      throw new Error('An unknown error occurred');
    }
  }
}

export const handler = async event => {
  console.log('Event:', event);

  // リクエストの検証
  try {
    verifyRequestSignature(event);
  } catch (error) {
    console.error('Signature verification failed:', error);
    return {
      statusCode: 401,
      body      : JSON.stringify({ error: 'Invalid signature' })
    };
  }
  console.log('Signature verification passed');

  // リクエストボディのパース
  const body = event.body ? JSON.parse(event.body) : {};

  // Slack からの URL 検証リクエストの場合
  if(body.type === 'url_verification') {
    console.log('URL verification request:', body);
    return {
      statusCode: 200,
      body      : JSON.stringify({
        challenge: body.challenge
      })
    };
  }

  // イベントがメッセージでない場合は無視
  if(body.event.type !== 'message') {
    console.log('メッセージがイベントでない');
    return {
      statusCode: 400,
      headers   : { 'Content-Type'    : 'application/json',
        'x-slack-no-retry': 1 },
      body: JSON.stringify({ message: 'メッセージがイベントではありません。' })
    };
  }

  // ボットの自己メッセージを無視
  if(body.event.bot_id) {
    console.log('ボットのメッセージに反応', body.event.text);
    return {
      statusCode: 200,
      body      : JSON.stringify({ message: 'ユーザーのメッセージではありません。' })
    };
  }

  // 再試行リクエストを無視
  if(event.headers['x-slack-retry-num']) {
    console.log('実行中');
    return { statusCode: 202, body: JSON.stringify({ message: '実行中' }) };
  }

  try {

    // エージェントの呼び出し
    const agentParams = {
      agentId        : process.env.BEDROCK_AGENT_ID,
      agentAliasId   : process.env.BEDROCK_AGENT_ALIAS_ID,
      sessionId      : body.event.user,
      inputText      : body.event.text,
      enableTrace    : false,
      enableStreaming: true
    };

    const response = await invokeAgent(agentParams);

    // Slackにメッセージを送信
    const res =  await fetch('https://slack.com/api/chat.postMessage', {
      method : 'POST',
      headers: {
        'Content-Type': 'application/json; charset=utf-8',
        Authorization : `Bearer ${process.env.SLACK_BOT_TOKEN}`
      },
      body: JSON.stringify({
        channel: body.event.channel,
        text   : response
      })
    });
    console.log('Slack response:', res);

    return {
      statusCode: 200,
      body      : JSON.stringify({ message: 'OK' })
    };
  } catch (error) {
    console.error('Error processing message:', error);
    return {
      statusCode: 500,
      body      : JSON.stringify({ error: 'Internal server error' })
    };
  }
};

セキュリティに関する注意事項

  • この署名検証の実装はSlackの公式ドキュメントに基づいています
  • Signing Secretは必ず環境変数として管理し、ソースコードには直接記述しないでください
  • 本番環境では、AWS Systems ManagerのParameter StoreやSecrets Managerでの管理を推奨します

公式ドキュメント↓
https://api.slack.com/authentication/verifying-requests-from-slack
HMACについて↓
https://en.wikipedia.org/wiki/HMAC

6.動作確認

DMでの動作確認

  1. Slackの左サイドバーでボットを見つけ、クリックしてDMを開きます。
  2. テストメッセージを送信します
    以下の画像のように正常に返答が得られれば成功です。

トラブルシューティング

もし正常に動作しない場合、以下の点を確認してください。

  1. 使用しているLambdaのクラウドウォッチログの確認
  2. 環境変数が正しく設定されているか確認
  3. SlackAppの設定で、すべてのスコープとイベントが正しく設定されているか確認

次のステップ

ストリーミングレスポンスの実装

https://zenn.dev/systemzeus_blog/articles/b4a3f7b6e4e198

免責事項

作者または著作権者は、契約行為、不法行為、またはそれ以外であろうと、ソフトウェアに起因または関連し、あるいはソフトウェアの使用またはその他の扱いによって生じる一切の請求、損害、その他の義務について何らの責任も負わないものとします。

株式会社システムゼウス

Discussion