# 生成AIの話は出てきません
TL;DR
Slack WorkFlowのフォームに入力したテキストを使って
Lambdaで画像を生成(ベース画像にテキストを描画)してSlackに投稿するBotを作ったよ
つまり...
こうじゃ!!
環境
Slack: 有料アカウント(Slack APPとWorkFlowBuilderを利用するため)
Lambda Runtime: Python 3.9
なぜ作るのか
私が所属しているKDDIアジャイル開発センター株式会社(以下KAG)では、週に1度レビュー会なるものを開催しています
新規プロダクトの紹介やカンファレンスの登壇レポなど、社内のメンバーに共有したいこと、
また、開発中のプロダクトについての相談やユーザテストなど、多くの人からフィードバックをもらいたいものまで、自由に発表できます。
任意参加ではありますが、多くの人が集まる良い文化だと感じています。
基本的には毎週各部持ち回りで発表を行いますが、事前の案内が下記↓のようなタイトルと発表者の情報のみになっています。
これでは参加する側が自分に興味のあるテーマか、また自分がレビュワーとして求められているのかが判断できません。
とはいえもっとリッチな、例えばスライド1枚分の、アジェンダを用意してもらうのは、発表者の負担が上がるのでやりたくはありません。
できらぁ!
えっ、レビュー会参加のハードルを極力上げずに、より優しく参加者に周知を!?
できらぁ!!
要件
誰が言ったか「宣伝用のチラシ」という案が気に入ったので最終的に生成した画像をアウトプットとします
レビュー会の周知はSlackで行なっているため、レビューの登録〜宣伝画像の出力等ユーザが触れる箇所は全てSlackで完結させたいです。
チラシ用の文章を一から全部考えてもらうのは大変なので、文章のテンプレはこちらで用意し、レビュイーには穴埋めをしてもらいます。
文章のテンプレがあるので、チラシ画像側も倣って、テンプレ画像を用意し、ユーザの入力値を描画するだけの状態にしておきます。
設計
画像を扱う以上Slack単体では難しいので、画像の処理はLambdaにお願いしましょう。
レビューエントリー用のSlack APPを作成し Lambdaと接続、Lambdaで生成した画像はSlackのIncomming Webhookを使ってSlackにポストします。
また、今回は入力項目が多くBotへの@メンションでは難しい(入力項目の順番を覚えるのが大変、エラー判別がし難い)ため、SlackのWorkFlowBuilderを使ってユーザの入力を補助します。
作ってみる
Slackの設定編
Slack WorkFlow ~ Slack APP(Bot)
今回はSlackからの入力値として 発表者
サービス名
レビュー内容
期待する参加者
の4項目を設定します。
ユーザからの入力はSlack WorkFlowを使用したいのでWorkFlowBuilderの手順に従って設定していきます(この時点ではまだLambdaは必要ありません)
参考:公式ドキュメント
フォームから直接Lambdaへのメッセージ送信ができないので、
Slack APPでBotユーザを作成し、フォームの入力内容をBotへ@メンションします。
今回はLambda側で各入力項目のパースを ,\n
で行いたかったため、各項目間を ,\n
で接続しています。
Slack APP(Bot) ~ Lambda
BotがWorkFlowからメンションを受け取ったらLambdaを起動させたいので、Slack APP側に設定を追加していきます。
今回は社内利用かつ、流量などを気にしなくて良い程度の用途なので、APIGatewayを挟まず、Lambda Function URLsで直接SlackとLambdaを接続します。
Lambda側の設定は後述。
↓の記事がやりたいことに近くてわかりやすかったです。
参考:Lambda関数URLを使ってお手軽に作るSlackの自動返答アプリ
環境構築(Lambdaをデプロイする)編
今回やったことの本質ではないものの、一番ハマったのはここ
Cloud Formationを使ってローカルからAWSにLambdaをデプロイするのは、慣れていないと毎回苦しむことになるので、参考記事をほぼそのまま使いました(特にbashスクリプト)
参考:Slackに定期通知するLambda関数をCloudFormationとAWS CLIを使ってデプロイしてみた
環境変数などを追加して、最終的にできたものはこれ
AWSTemplateFormatVersion: '2010-09-09'
Description: Lambda Template
Parameters:
CodeS3Key:
Type: String
DeploymentBucket:
Type: String
LambdaEnvSlackApiId:
Type: String
LambdaEnvSlackHookUrl:
Type: String
LambdaEnvImageBucket:
Type: String
LambdaEnvBotUserOauthToken:
Type: String
LambdaEnvVerificationToken:
Type: String
LambdaEnvSlackSendChannel:
Type: String
Resources:
ReviewEntryFunction:
Type: AWS::Lambda::Function
Properties:
Description: "LambdaFunction send image to slack"
FunctionName: "ReviewEntryFunction"
Handler: lambda.handler
MemorySize: 128
Role: !GetAtt
- ReviewEntryLambdaRoles
- Arn
Runtime: python3.9
Timeout: 60
Environment:
Variables:
SLACK_API_ID: !Ref LambdaEnvSlackApiId
SLACK_HOOK_URL: !Ref LambdaEnvSlackHookUrl
VERIFICATION_TOKEN: !Ref LambdaEnvVerificationToken
BOT_USER_OAUTH_TOKEN: !Ref LambdaEnvBotUserOauthToken
IMG_BUCKET_NAME: !Ref LambdaEnvImageBucket
SLACK_SEND_CHANNEL: !Ref LambdaEnvSlackSendChannel
Code:
S3Bucket: !Ref DeploymentBucket
S3Key: !Ref CodeS3Key
Layers:
- "{{arn:aws:of_Lambda_Layer:PIL_modules}}"
- "{{arn:aws:of_Lambda_Layer:JP_Font_File}}"
DependsOn:
- ReviewEntryLambdaRoles
ReviewEntryURL:
Type: AWS::Lambda::Url
Properties:
TargetFunctionArn: !Ref ReviewEntryFunction
AuthType: NONE
Cors:
AllowCredentials: false
AllowMethods:
- GET
- POST
AllowOrigins:
- "*"
ReviewEntryLambdaRoles:
Type: AWS::IAM::Role
Properties:
RoleName: "ReviewEntryLambdaRoles"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Principal:
Service:
- lambda.amazonaws.com
Action: 'sts:AssumeRole'
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaRole
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
- arn:aws:iam::aws:policy/AmazonS3FullAccess
ReviewEntryLambdaRolePolicy:
Type: AWS::IAM::Policy
Properties:
PolicyName: "ReviewEntryLambdaRolePolicy"
Roles:
- !Ref ReviewEntryLambdaRoles
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action:
- 'sts:AssumeRole'
Resource: '*'
補足:
- Lambdaの環境変数などは外部パラメータとしてCFNの外側から与えるようにしています
- S3バケットとLambda Layer(後述)はこのCFNでは扱っていません(今回は手動で作成しています)
- ロール権限に甘いところがあります
Lambda Layer
Lambdaは標準で入っていない外部ライブラリはZipで固めるなどして、手動で渡してあげる必要があるのですが、
今回使用した画像操作用のライブラリPIL(Pillow)は Pythonコードの実行環境に合わせたものを 使わないといけないとのこと
今回のLambdaランタイムはPython3.9なので、実行環境はAmazon Linux2。
つまりMacOSやWindowsなローカル環境で pip install Pillow
しただけではうまくいきません。
やりようは色々とありますが(↓下記記事にまとめられています)、今回はwhlファイルを用いる方法を取りました。
参考:[AWS Lambda] Pythonで外部モジュール(Pillow)を使う
Amazon Linux2用のwhlファイルをDL→Zip化して Lambda Layer化します。
(Zipファイルのアップロードを自動化しなかったのでLayerの作成は手動にしています。)
他の外部ライブラリについてはローカルでインストールする際にPillowと同じディレクトリ配下を指定して保存し、同じLambda Layerにまとめています。
また、Pillowでテキストを描画する際にはフォントの指定が必要です。
が、当然ながらLambdaでは日本語フォントのサポートはありません。
そのため、Pillowと同じくフォントデータをLambda Layerとしてアップロードします。
フォント自体はローカルのシステムに入っているものを使って問題ないはずです。
参考:Lamda上のmatplotlibで日本語対応する
Pythonコードを書く
import json
import logging
import os
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
import boto3
from PIL import Image, ImageDraw, ImageFont
from slack_sdk import WebClient
from io import BytesIO
BUCKET_NAME = os.environ['IMG_BUCKET_NAME']
OBJECT_KEY_NAME = "OUT_PUT_IMAGE_PATH"
s3_client = boto3.client('s3')
logger = logging.getLogger('lambda_logger')
logger.setLevel(logging.INFO)
slack_hook_url = os.environ["SLACK_HOOK_URL"]
slack_client = WebClient(os.environ["BOT_USER_OAUTH_TOKEN"])
def send_message(key):
res_img = s3_client.get_object(Bucket=BUCKET_NAME, Key=key)
body = res_img["Body"].read()
bytes_body = BytesIO(body)
slack_client.files_upload(
title="ここが画像と一緒にSlackで表示される",
filename="IMAGE_FILE_NAME.png",
file = bytes_body,
filetype = "png",
channels="#SLACK_CHANNEL_NAME"
)
def draw_image(input_text):
# Lambda Layerの日本語フォントを指定
font_path = '/opt/assets/fonts/Hiragino.ttc'
# ベース画像に合うように決めうちで設定
font_size = 30
color = (250, 60, 0)
# Slackメッセージは `@bot名,\n発表者,\nサービス名,\nレビュー内容,\n期待する参加者` で送られてくる
_, text_name, text_service_name, text_review_content, text_target = input_text.split(",\n")
# S3バケットからベース画像を取得
base_img_s3_obj = s3_client.get_object(
Bucket = BUCKET_NAME,
Key = 'review-image/baseImage.png'
)
# 画像ファイルをバイナリで読み込み→PILで扱える形式に変換
img_data = BytesIO(base_img_s3_obj['Body'].read())
pil_image = Image.open(img_data)
# 読み込んだベース画像にテキストを描画する
font = ImageFont.truetype(font_path, font_size)
draw = ImageDraw.Draw(pil_image)
draw.text((120, 75), text_name, font=font, fill=color)
draw.text((120, 180), text_service_name, font=font, fill=color)
draw.text((120, 290), text_review_content, font=font, fill=color)
draw.text((120, 540), text_target, font=font, fill=color)
# 画像を保存するための領域を確保
buf = BytesIO()
pil_image.save(buf, 'png')
key = OBJECT_KEY_NAME
# S3に保存
s3_client.put_object(
Bucket=os.environ['IMG_BUCKET_NAME'],
Key=key,
Body=buf.getvalue()
)
return key
def isSlackRetryMessage(event):
request_header = event["headers"]
keys = request_header.keys()
return "x-slack-retry-num" in keys and "x-slack-retry-reason" in keys and request_header["x-slack-retry-reason"] == "http_timeout"
def handler(event, context):
logger.info('event: %s', event)
logger.info('context: %s', context)
# タイムアウトが原因のSlack Events APIの再送を止める処理
if (isSlackRetryMessage(event)):
return {
'statusCode': 200,
'body': {
'message': 'No need to resend'
}
}
# Slackからのメッセージは event.body.event.textに格納されている
body_obj = json.loads(event["body"])
input_text = body_obj["event"]["text"]
img_key = draw_image(input_text)
send_message(img_key)
return {
'statusCode': 200,
'body': {
'message': 'success to send.'
}
}
チャレンジコード(url verification)
Slack APPでメッセージの送信先(RequestURL)を指定する際に、url verification(チャレンジ)を突破する必要があります。
今回のSlack APPでもurl verificationは実施しましたが、最終的なLambdaのコードには含めていません。
参考:url_verification
Slack Events APIの再送処理
Lambdaへメッセージを送信するSlack Events APIの仕様として メッセージ送信から3秒以上レスポンスがなければリトライする
というものがあります。
今回はLambdaの中で画像処理を行うので、このリトライはほぼ回避できません。
回避策として画像処理〜Slackポストをキューイングするなど方法はありますが、
参考:Slack Events APIの再送仕様と回避方法まとめ(Serverless on AWS)
今回は単純に Slackからリトライ分のメッセージが来たら無視する(即200応答する)
にしました。
完成した...っ
これで当初やりたかった フォーム入力をトリガーにレビューエントリー告知画像を生成してSlackで周知する が大体できました。
完成したとはいえ、まだ色々とプロトタイプの域を出ていないところが多く、先週のKAGレビュー会でも様々なフィードバックをいただきました。
今後も改善しつつ、良いレビュー会
に貢献できるツールになっていければと思います。
また、この構成は入力がテキストフォームで出力がファイルであれば同じ構成にできるので、レビューエントリーに限らず他のネタに流用しやすいのでは? とも考えています