Slackに入力したテキストを使ってLambdaで画像を生成して返す

\# 生成AIの話は出てきません

# TL;DR
Slack WorkFlowのフォームに入力したテキストを使って  
Lambdaで画像を生成（ベース画像にテキストを描画）してSlackに投稿するBotを作ったよ


つまり...  
  



**これが**
![スクリーンショット 2023-06-23 18.35.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/98924b77-22cc-d670-5ffa-56546ddbc70d.png)
  


**こうじゃ！！**


![スクリーンショット 2023-06-23 18.36.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/e9e31e9e-be28-4b61-1bb4-c18bd10cf933.png)

# 環境  
Slack: 有料アカウント（Slack APPとWorkFlowBuilderを利用するため）
Lambda Runtime: Python 3.9

登場人物
![スクリーンショット 2023-06-26 14.26.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/381df72f-5cae-d440-9668-753e74dc1361.png)


# なぜ作るのか
私が所属しているKDDIアジャイル開発センター株式会社（以下KAG）では、週に１度レビュー会なるものを開催しています  
新規プロダクトの紹介やカンファレンスの登壇レポなど、社内のメンバーに共有したいこと、  
また、開発中のプロダクトについての相談やユーザテストなど、多くの人からフィードバックをもらいたいものまで、自由に発表できます。  
任意参加ではありますが、多くの人が集まる良い文化だと感じています。  

基本的には毎週各部持ち回りで発表を行いますが、事前の案内が下記↓のようなタイトルと発表者の情報のみになっています。  

![スクリーンショット 2023-06-26 11.30.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/be8a6074-4c05-8e09-a44d-e90b3a71c9f5.png)

これでは参加する側が自分に興味のあるテーマか、また自分がレビュワーとして求められているのかが判断できません。
とはいえもっとリッチな、例えばスライド１枚分の、アジェンダを用意してもらうのは、発表者の負担が上がるのでやりたくはありません。  

**できらぁ！**  
えっ、レビュー会参加のハードルを極力上げずに、より優しく参加者に周知を！？  

#  できらぁ！！  
## 要件
誰が言ったか「宣伝用のチラシ」という案が気に入ったので最終的に生成した画像をアウトプットとします
レビュー会の周知はSlackで行なっているため、レビューの登録〜宣伝画像の出力等ユーザが触れる箇所は全てSlackで完結させたいです。  
チラシ用の文章を一から全部考えてもらうのは大変なので、文章のテンプレはこちらで用意し、レビュイーには穴埋めをしてもらいます。
文章のテンプレがあるので、チラシ画像側も倣って、テンプレ画像を用意し、ユーザの入力値を描画するだけの状態にしておきます。  
![スクリーンショット 2023-06-26 12.10.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/749c0169-9c21-4f78-a8b3-fe2dd6588aae.png)


## 設計
画像を扱う以上Slack単体では難しいので、画像の処理はLambdaにお願いしましょう。  
レビューエントリー用のSlack APPを作成し Lambdaと接続、Lambdaで生成した画像はSlackのIncomming Webhookを使ってSlackにポストします。  
また、今回は入力項目が多くBotへの@メンションでは難しい（入力項目の順番を覚えるのが大変、エラー判別がし難い）ため、SlackのWorkFlowBuilderを使ってユーザの入力を補助します。  


ここまでをまとめると、こうなります
![スクリーンショット 2023-06-26 14.29.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/0de13935-ed3a-6da0-49c2-376fe5349591.png)


# 作ってみる  
## Slackの設定編  
### Slack WorkFlow ~ Slack APP(Bot)

今回はSlackからの入力値として `発表者` `サービス名` `レビュー内容` `期待する参加者`の４項目を設定します。
ユーザからの入力はSlack WorkFlowを使用したいのでWorkFlowBuilderの手順に従って設定していきます（この時点ではまだLambdaは必要ありません）
[参考：公式ドキュメント](https://slack.com/intl/ja-jp/help/articles/360035692513-%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%83%93%E3%83%AB%E3%83%80%E3%83%BC%E3%82%AC%E3%82%A4%E3%83%89?utm_source=slack&utm_medium=prod&utm_campaign=hc#workflow-visibility)

フォームから直接Lambdaへのメッセージ送信ができないので、  
Slack APPでBotユーザを作成し、フォームの入力内容をBotへ@メンションします。  
今回はLambda側で各入力項目のパースを `,\n` で行いたかったため、各項目間を `,\n` で接続しています。

![スクリーンショット 2023-06-26 16.23.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/f01f44eb-93c5-3fad-899a-39748ce8f49d.png)

### Slack APP(Bot) ~ Lambda
BotがWorkFlowからメンションを受け取ったらLambdaを起動させたいので、Slack APP側に設定を追加していきます。  
今回は社内利用かつ、流量などを気にしなくて良い程度の用途なので、APIGatewayを挟まず、Lambda Function URLsで直接SlackとLambdaを接続します。
Lambda側の設定は後述。

↓の記事がやりたいことに近くてわかりやすかったです。
[参考：Lambda関数URLを使ってお手軽に作るSlackの自動返答アプリ](https://toranoana-lab.hatenablog.com/entry/2022/04/26/100000)



## 環境構築(Lambdaをデプロイする)編  
今回やったことの本質ではないものの、一番ハマったのはここ

Cloud Formationを使ってローカルからAWSにLambdaをデプロイするのは、慣れていないと毎回苦しむことになるので、参考記事をほぼそのまま使いました（特にbashスクリプト）
[参考：Slackに定期通知するLambda関数をCloudFormationとAWS CLIを使ってデプロイしてみた](https://dev.classmethod.jp/articles/deploy_lambda_function_with_cli/)
  
環境変数などを追加して、最終的にできたものはこれ
```yaml
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: '*'
```
補足： 
1. Lambdaの環境変数などは外部パラメータとしてCFNの外側から与えるようにしています
1. S3バケットとLambda Layer(後述)はこのCFNでは扱っていません（今回は手動で作成しています）
1. ロール権限に甘いところがあります

### Lambda Layer
Lambdaは標準で入っていない外部ライブラリはZipで固めるなどして、手動で渡してあげる必要があるのですが、  
今回使用した画像操作用のライブラリPIL（Pillow）は **Pythonコードの実行環境に合わせたものを** 使わないといけないとのこと  
今回のLambdaランタイムはPython3.9なので、実行環境はAmazon Linux2。
つまりMacOSやWindowsなローカル環境で `pip install Pillow` しただけではうまくいきません。  
やりようは色々とありますが（↓下記記事にまとめられています)、今回はwhlファイルを用いる方法を取りました。
[参考：[AWS Lambda] Pythonで外部モジュール(Pillow)を使う](https://qiita.com/Bashi50/items/1f72a93dbde23de55dda)
Amazon Linux2用のwhlファイルをDL→Zip化して　Lambda Layer化します。  
（Zipファイルのアップロードを自動化しなかったのでLayerの作成は手動にしています。）
他の外部ライブラリについてはローカルでインストールする際にPillowと同じディレクトリ配下を指定して保存し、同じLambda Layerにまとめています。

また、Pillowでテキストを描画する際にはフォントの指定が必要です。  
が、当然ながらLambdaでは日本語フォントのサポートはありません。
そのため、Pillowと同じくフォントデータをLambda Layerとしてアップロードします。
フォント自体はローカルのシステムに入っているものを使って問題ないはずです。
[参考：Lamda上のmatplotlibで日本語対応する](https://qiita.com/shuheilocale/items/e4b33f9af18d3b11c850)


## Pythonコードを書く


```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](https://api.slack.com/events/url_verification)

### Slack Events APIの再送処理  
Lambdaへメッセージを送信するSlack Events APIの仕様として `メッセージ送信から3秒以上レスポンスがなければリトライする` というものがあります。  
今回はLambdaの中で画像処理を行うので、このリトライはほぼ回避できません。
回避策として画像処理〜Slackポストをキューイングするなど方法はありますが、
[参考：Slack Events APIの再送仕様と回避方法まとめ(Serverless on AWS)](https://dev.classmethod.jp/articles/slack-resend-matome/)
今回は単純に `Slackからリトライ分のメッセージが来たら無視する(即200応答する)` にしました。


# 完成した...っ
これで当初やりたかった　フォーム入力をトリガーにレビューエントリー告知画像を生成してSlackで周知する　が大体できました。　　
完成したとはいえ、まだ色々とプロトタイプの域を出ていないところが多く、先週のKAGレビュー会でも様々なフィードバックをいただきました。
今後も改善しつつ、`良いレビュー会` に貢献できるツールになっていければと思います。　　
  
また、この構成は入力がテキストフォームで出力がファイルであれば同じ構成にできるので、レビューエントリーに限らず他のネタに流用しやすいのでは？　とも考えています