親しいメンバーでわちゃわちゃしてるSlackに入れてるAIボットの中身を、今までOpenAI APIキーとGAS (Google Apps Script)で動かしていた。
ただ最近、急にメンションに反応しなくなって、クレジット切れか?と思ってコンソール見たら4ドル残ってたはずのクレジットが一気にゼロになってた、Why?
どうやらOpenAIの仕様で、一定期間過ぎたKeyはどれだけクレジットが残っていてもExpiredとなって死ぬそう。
まあちゃんと仕様を理解してない自分も悪いけど、さすがにこれは意地悪じゃないかと思ってもうOpenAIくんと決別。
代替案としてGeminiくんに聞いたらAWS BedrockにしとけってことだったのでちょうどAWSの勉強にもなるし、中身を移行しました。

Contents 非表示
AI Botの内部構成
(Nano Bananaで作りました)

簡潔な流れ:
- SlackでBotのメンション付きのメッセージ投稿、Slack ConsoleのEvent Subscriptionが発火
- Event SubscriptionからAPI Gatewayに到達、Slackの仕様上、3秒ルールとしてなるべく早くレスを返さないと再送されてリクエストが重複するので、即座に200を返すLambdaを用意
- 非同期で、200を即レスするLambdaから、LLMリクエスト用のLambdaに橋渡し実際のBedRockリクエストをはここで処理する
- 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のセットアップ
- REST API を作成(エンドポイントタイプ: リージョン)
- リソース
/slack/eventsを作成 - POST メソッドを追加
- 統合タイプ: Lambda関数
- Lambda プロキシ統合: ON(必須)
- Lambda関数:
slack-bot-responder
- ステージ
prodにデプロイ - 発行されたエンドポイントを控える
- 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 URL | API Gateway のエンドポイント |
| Subscribe to bot events | app_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に登録
- AWSコンソールから、Secret Managerを選択する。
- slack-bot/tokensという名前で作成
- { “bot_token”: “xoxb-xxxx”, “signing_secret”: “xxxxx” }として登録、保存しておく

Key-valueセットできちんと読み込まれていればOK、Lambdaのprocessor側はここにSecretを読み込みにいきます。
動作確認
Slack全体をリロードして、botメンションを送って返信が返ってこればOK、AWS側でログも見れるのでそちらを見るのが確実かも。


