SlackおしゃべりBotの内部をOpenAI APIからAWS Bedrockに移行した

親しいメンバーでわちゃわちゃしてるSlackに入れてるAIボットの中身を、今までOpenAI APIキーとGAS (Google Apps Script)で動かしていた。

ただ最近、急にメンションに反応しなくなって、クレジット切れか?と思ってコンソール見たら4ドル残ってたはずのクレジットが一気にゼロになってた、Why?

どうやらOpenAIの仕様で、一定期間過ぎたKeyはどれだけクレジットが残っていてもExpiredとなって死ぬそう。

まあちゃんと仕様を理解してない自分も悪いけど、さすがにこれは意地悪じゃないかと思ってもうOpenAIくんと決別。

代替案としてGeminiくんに聞いたらAWS BedrockにしとけってことだったのでちょうどAWSの勉強にもなるし、中身を移行しました。


AI Botの内部構成

(Nano Bananaで作りました)

簡潔な流れ:

  1. SlackでBotのメンション付きのメッセージ投稿、Slack ConsoleのEvent Subscriptionが発火
  2. Event SubscriptionからAPI Gatewayに到達、Slackの仕様上、3秒ルールとしてなるべく早くレスを返さないと再送されてリクエストが重複するので、即座に200を返すLambdaを用意
  3. 非同期で、200を即レスするLambdaから、LLMリクエスト用のLambdaに橋渡し実際のBedRockリクエストをはここで処理する
  4. BedRockから返ってきたレスポンスをSlackのts (タイムスタンプ)にむけて返却

Lambdaを2個用意しないといけないのが若干面倒ですが、これを入れておかないとSlack再送処理が走ってBotから二重に回答返ってくるのでこれは仕方ない部分と割り切りましょう、個人の範囲ならLambda無料枠で全然事足りると思うので。

というわけでこのBotの構成詳細を書いていきます。

前提条件:

  • SlackのBotはすでに作成して、ワークスペースに入っていること
  • AWSアカウントを持っていること、ある程度AWSコンソールの操作がわかっていること (今回は全てGUI操作で進めます)
  • 筆者は本業サーバーサイドエンジニアじゃないので、正直AWS側の構成に長けてるわけではないこと (最低限のセキュリティ知識は持った前提での構成にはしているつもりです)
  • LambdaやAPI Gatewayを使うので多少月にコストがかかる可能性を受け入れられる人 (といっても個人利用とかの範囲ならほぼ無料枠に収まるとは思います
  • 技術スタック: 今回はPythonでLambdaを構成

AWS側のセットアップ

Lambdaのセットアップ

  • slack-bot-responder (即200を返して、内部でprocessorに処理進ませるLambda)
  • slack-bot-processor (実際にBedrockにリクエストして、SlackにBotメッセージを返却するLambda)

この2つを使用します。


slack-bot-responder

項目
ランタイムPython 3.12
タイムアウト5秒
メモリ128MB

IAM ロール権限:

{
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["lambda:InvokeFunction"],
      "Resource": "arn:aws:lambda:*:*:function:slack-bot-processor"
    },
    {
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "*"
    }
  ]
}

コード:

import json
import boto3
import hashlib
import hmac
import os
import time

lambda_client = boto3.client('lambda')

def verify_slack_signature(headers: dict, body: str, signing_secret: str) -> bool:
    """Slackリクエストの署名検証"""
    timestamp = headers.get('X-Slack-Request-Timestamp', '')
    # リプレイアタック防止(5分以上古いリクエストを拒否)
    if abs(time.time() - int(timestamp)) > 300:
        return False
    sig_basestring = f"v0:{timestamp}:{body}"
    my_signature = 'v0=' + hmac.new(
        signing_secret.encode(),
        sig_basestring.encode(),
        hashlib.sha256
    ).hexdigest()
    slack_signature = headers.get('X-Slack-Signature', '')
    return hmac.compare_digest(my_signature, slack_signature)

def lambda_handler(event, context):
    headers = event.get('headers', {})
    body_str = event.get('body', '{}')
    
    # Slackのリトライを無視(重複処理防止)
    if headers.get('X-Slack-Retry-Num'):
        return {'statusCode': 200, 'body': json.dumps({'status': 'ignored retry'})}
    
    body = json.loads(body_str)
    
    # Slack URL Verification(初回設定時)
    if body.get('type') == 'url_verification':
        return {'statusCode': 200, 'body': json.dumps({'challenge': body['challenge']})}
    
    # Lambda #2 を非同期で呼び出し
    lambda_client.invoke(
        FunctionName='slack-bot-processor',
        InvocationType='Event',  # 非同期呼び出し(レスポンスを待たない)
        Payload=json.dumps(body)
    )
    
    # Slackへ即座に200を返却
    return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})}

slack-bot-processor

項目
ランタイムPython 3.12
タイムアウト60秒
メモリ256MB

IAM ロール権限:

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "BedrockInvoke",
			"Effect": "Allow",
			"Action": [
				"bedrock:InvokeModel"
			],
			"Resource": "arn:aws:bedrock:*::foundation-model/*"
		},
		{
			"Sid": "CloudWatchLogs",
			"Effect": "Allow",
			"Action": [
				"logs:CreateLogGroup",
				"logs:CreateLogStream",
				"logs:PutLogEvents"
			],
			"Resource": "*"
		},
		{
			"Sid": "SecretsManagerRead",
			"Effect": "Allow",
			"Action": [
				"secretsmanager:GetSecretValue",
				"secretsmanager:DescribeSecret"
			],
			"Resource": "arn:aws:secretsmanager:*:*:secret:*"
		}
	]
}

コード:

import json
import boto3
import urllib.request
import os

bedrock = boto3.client('bedrock-runtime', region_name='ap-northeast-1')
ssm = boto3.client('secretsmanager')

def get_slack_token() -> str:
    secret = ssm.get_secret_value(SecretId='slack-bot/tokens')
    return json.loads(secret['SecretString'])['bot_token']

def get_thread_history(channel: str, thread_ts: str, token: str, limit: int = 20) -> list:
    """
    Slackスレッドの履歴を取得し、Bedrock用のmessages配列に変換
    戻り値: [{"role": "user"/"assistant", "content": [{"text": "..."}]}, ...]
    """
    url = (
        f"https://slack.com/api/conversations.replies"
        f"?channel={channel}&ts={thread_ts}&limit={limit}"
    )
    req = urllib.request.Request(
        url,
        headers={'Authorization': f'Bearer {token}'}
    )
    with urllib.request.urlopen(req) as res:
        data = json.loads(res.read())

    if not data.get('ok'):
        # 履歴取得失敗時は空リストを返して処理継続
        return []

    messages = []
    for msg in data.get('messages', []):
        text = msg.get('text', '').strip()
        if not text:
            continue

        # Bot発言 → assistant / それ以外 → user
        role = 'assistant' if msg.get('bot_id') else 'user'
        messages.append({
            'role': role,
            'content': [{'text': text}]
        })

    return messages

def call_bedrock(user_message: str, history: list = None) -> str:
    """
    Amazon Nova Lite を呼び出し
    history: Threadの過去メッセージ(Bedrock形式)
    """
    if history:
        # 履歴あり: 過去メッセージ + 最新メンションを結合
        # ※ 最後のメッセージが最新のメンションなので重複を避けるため
        #   historyの末尾がすでに同じ内容の場合はそのまま使う
        messages = history
        # historyの最後がuserかつ同一メッセージでなければ追加
        if not messages or messages[-1].get('content', [{}])[0].get('text') != user_message:
            messages.append({
                'role': 'user',
                'content': [{'text': user_message}]
            })
    else:
        # 履歴なし: 通常の単発質問
        messages = [
            {
                'role': 'user',
                'content': [{'text': user_message}]
            }
        ]

    payload = {
        'messages': messages,
        'inferenceConfig': {
            'maxTokens': 1024,
            'temperature': 0.7
        }
    }
    response = bedrock.invoke_model(
        modelId='amazon.nova-lite-v1:0',
        body=json.dumps(payload),
        contentType='application/json'
    )
    result = json.loads(response['body'].read())
    return result['output']['message']['content'][0]['text']

def post_to_slack(channel: str, text: str, thread_ts: str = None):
    """Slack APIへメッセージを投稿"""
    token = get_slack_token()
    payload = {'channel': channel, 'text': text}
    if thread_ts:
        payload['thread_ts'] = thread_ts
    req = urllib.request.Request(
        'https://slack.com/api/chat.postMessage',
        data=json.dumps(payload).encode(),
        headers={
            'Authorization': f'Bearer {token}',
            'Content-Type': 'application/json'
        }
    )
    urllib.request.urlopen(req)

def lambda_handler(event, context):
    channel = None
    try:
        slack_event = event.get('event', {})

        if slack_event.get('type') != 'app_mention':
            return
        if slack_event.get('bot_id'):
            return

        user_message = slack_event.get('text', '')
        channel = slack_event.get('channel')
        event_ts = slack_event.get('ts')         # このメッセージ自身のts
        thread_ts = slack_event.get('thread_ts') # スレッド親のts(Thread内のみ存在)

        token = get_slack_token()

        if thread_ts:
            # Thread内メンション: 履歴を取得してBedrockに渡す
            history = get_thread_history(channel, thread_ts, token, limit=20)
            response_text = call_bedrock(user_message, history)
            # 返信はスレッド内に投稿
            post_to_slack(channel, response_text, thread_ts=thread_ts)
        else:
            # 通常メンション(Thread外): 従来通り
            response_text = call_bedrock(user_message)
            post_to_slack(channel, response_text, thread_ts=event_ts)

    except Exception as e:
        if channel:
            try:
                post_to_slack(channel, "⚠️ エラーが発生しました。しばらく後にお試しください。")
            except:
                pass
        raise e

完成したら、Deployして一旦放置でOK。(API Gateway繋ぎこみ終わってからでもOK)


API Gatewayのセットアップ

  1. REST API を作成(エンドポイントタイプ: リージョン)
  2. リソース /slack/events を作成
  3. POST メソッドを追加
    • 統合タイプ: Lambda関数
    • Lambda プロキシ統合: ON(必須)
    • Lambda関数: slack-bot-responder
  4. ステージ prod にデプロイ
  5. 発行されたエンドポイントを控える
    • https://xxx.com/prod/slack/events (こんな感じのやつ)
    • API Key Requiredは一旦falseでOK (エンドポイントは自分以外に教えないように)
      • 一旦Botが動くことを確認した後にONにしてAPI Key必須にする構成にしておきましょう

Lambda プロキシ統合を ON にしないと X-Slack-Signature などのヘッダーが Lambda に届かないので、設定でONにしておくことを忘れずに。


Slack Console の設定

OAuth & Permissions — Bot Token Scopes

スコープ用途
app_mentions:readメンション受信
chat:writeメッセージ送信
channels:historyパブリックチャンネル履歴
groups:historyプライベートチャンネル履歴

Event Subscriptions

項目
Request URLAPI Gateway のエンドポイント
Subscribe to bot eventsapp_mention のみ

VerifiedになっていればOK。

設定変更後は必ず Reinstall App を実行する。

Bot User OAuth TokenとSigning Secretをコピーしておく (あとでAWSに保存するため)

OAth & Permission項目からBot User OAuth Tokenを、Basic InformationからSigning Secretをコピーして一旦PCのどこかにメモしてください、あとでAWSにシークレット保存します。

シークレットをAWS Secret Managerに登録

  1. AWSコンソールから、Secret Managerを選択する。
  2. slack-bot/tokensという名前で作成
    • { “bot_token”: “xoxb-xxxx”, “signing_secret”: “xxxxx” }として登録、保存しておく

Key-valueセットできちんと読み込まれていればOK、Lambdaのprocessor側はここにSecretを読み込みにいきます。


動作確認

Slack全体をリロードして、botメンションを送って返信が返ってこればOK、AWS側でログも見れるのでそちらを見るのが確実かも。