🐡

スタンプを自動で作って登録してくれるDiscord Botを作ってみた!(Lambda&Python)

2024/07/27に公開

はじめに

  • Discordで作ってほしいスタンプの文字を打つと、そのスタンプを作って、Discordの絵文字として追加してくれるボットが欲しいなと思って、作り始めました!
  • サーバー代を安くするためにLambdaを利用しています。
  • こんな感じのスタンプを作ります!
    3

作ったもの

  • /と打ちます。
  • /emoji create/emoji deleteを選択することができます。
    1
  • text項目に、スタンプとして作りたい文字を入力します。
  • color項目で、スタンプの色を選択します。
    2
  • スタンプを作成し、Discordにスタンプを送ってくれます。
  • さらに、自動でDiscordに絵文字を登録してくれます。
    3
  • スタンプの名前は、:スタンプのローマ字_色:で登録されます。

Discord Botをサーバーレスで作るポイント

  • discordにメッセージを送信してLambdaを起動させるというのが、現状できないです。EC2などのように常時サーバーを起動させると可能らしいですが、地味にお金がかかります。

  • /から始まる「スラッシュコマンド」という機能を使えばLambdaでも作成できます!

  • 下のようなやつです

  • また、スラッシュコマンドでLambdaに通知を送信すると、3秒以内にDiscordに反応を返さないといけないという縛りプレイがあります。

  • そのため、Lambda関数の処理で3秒を超える重たい処理をさせる場合は、1つ目のlambda関数でDEFERRED_CHANNEL_MESSAGE_WITH_SOURCEという「後で絶対返事だすからね!」という返事を一旦返しておき、2つ目のlambda関数を非同期で実行させて、最終の返事を出すという方針になります。

使った技術

  • 仕様言語:Python3.11
  • モジュール
    • pykakasi:漢字&ひらがな → ローマ字変換
    • pillow:画像処理ライブラリ(文字を配置し画像に)
    • discord_interactions:discordの認証
    • boto3:lambdaからlmabdaを呼び出すために利用
    • requests:discord へメッセージを返信

AWS構成図

lambdaの説明

1. lambda(entorypoint-enoji)

  • discordからのコマンド通知(/emoji create ~)を受け取る
  • 正式なdiscord botからの通知かどうかの認証が完了すると、スタンプを作成するlambda(emoji-create)を非同期で呼び出す。
  • InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCEをdisocrdに返し、discordの待機時間を延長させる(discordは、3秒以内に応答しないとエラーになるので、時間がかかりますよ~次の処理で絶対に返事するからちょっと待ってて~と返事しておく)

2. lambda(emoji-create)

  • スタンプを作成する
  • 作成し終わると、discordに作成し終えた絵文字を送り、また、discord apiを使用してスタンプを登録する

https://discord.com/developers/docs/resources/emoji#create-guild-emoji

最終フォルダ構成

作り方

Discord Botの作成

  • 以下のリンクからBotを作成できます。

https://discord.com/developers/applications

  • New Aplicationを押します。

  • ボットの名前を決めてcreateを押します。

  • 愛着がわくように、可愛いアイコンを登録しましょう

  • APPLICATION IDPUBLIC KEYは後で使うので、メモ帳にコピーしておきます(いつでも見ることができるので忘れたら見に戻りましょう)。

  • Botを選択します。
  • TOKENに、Reset Tokenというボタンがあるので押します。
  • Tokenを発行して、コピーしておきます。

サーバーにBotを招待するリンクを作成

  • OAuth2を選択します

  • SCOPESの中にある、applications.commandsbotにチェックを入れます。

  • さらに、その下のBOT PERMISSIONSの中にある、Manage Expressionsにチェックを入れます。

  • 絵文字の追加や削除の許可を行っています。

  • 一番下に、GENERATED URLでURLが作成されていると思うので、そのリンクを取得します。

  • このリンクが、ボットを招待できるリンクになります。もし、たくさんの人にボットを公開したい場合、このリンクを配布してボットを追加してもらいます。

  • このリンクを使って、自分のDiscordサーバーにもボットを追加しておきましょう。

  • 招待できましたか?自分のBotは、可愛いですね。。

環境構築

  • AWS Cloud9を利用して作成しました。

https://hikoniki.com/2023/07/10/lambda-で-pillow-のモジュールがエラーで動かない件につい/

  • 以下のサイトを参考にCloud9にPython3.11のインストールします!

https://note.com/fair_sedum522/n/n937943bd4cfa

スラッシュコマンドの作成

  • 環境構築できたので、先ほど作ったボットにスラッシュコマンドを登録しましょう!
  • このコードは、一回実行するだけで、コマンドとしてボットに登録されるので、ぱっと登録しておきましょう。
  • 以下のドキュメントを参考にスラッシュコマンドを登録するPythonコードを作成しました。
  • また、今度の記事でコードを解説したいと思います。

https://discord.com/developers/docs/interactions/application-commands

  • requestsをインストールします。
pip install requests
  • register_commands.pyを作成します。
  • application_idbot_tokenには、先ほどコピーした値を入れて下さい。
register_commands.py
register_commands.py
import requests

# 環境変数または直接値を設定します
application_id = '自分のIDを入れてください'
bot_token = '自分のボットトークンを入れてください'

url = f"https://discord.com/api/v10/applications/{application_id}/commands"

# コマンドのデータ
json = {
    "name": "emoji",
    "type": 1,
    "description": "絵文字に関するコマンドです",
    "options": [
        {
            "name": "create",
            "description": "スタンプを作ります",
            "type": 1,
            "options": [
                {
                    "name": "text",
                    "description": "Text to include in the stamp",
                    "type": 3,
                    "required": True
                },
                {
                    "name": "color",
                    "description": "色を選択",
                    "type": 3,
                    "required": False,
                    "choices": [
                        {"name": "red", "value": "red"},
                        {"name": "green", "value": "green"},
                        {"name": "blue", "value": "blue"},
                        {"name": "black", "value": "black"},
                        {"name": "white", "value": "white"},
                        {"name": "yellow", "value": "yellow"},
                        {"name": "cyan", "value": "cyan"},
                        {"name": "magenta", "value": "magenta"},
                        {"name": "gray", "value": "gray"}
                    ]
                }
            ]
        },
        {
            "name": "delete",
            "description": "スタンプを削除します",
            "type": 1,
            "options": [
                {
                    "name": "text",
                    "description": "消したい文字の入力",
                    "type": 3,
                    "required": True
                }
            ]
        }
    ]
}

headers = {
    "Authorization": f"Bot {bot_token}"
}

# POSTリクエストを送信してコマンドを登録
response = requests.post(url, headers=headers, json=json)

# レスポンスを表示
print(response.json())

  • python3コマンドで、register_commands.pyファイルを実行します。
python3 register_commands.py
  • Botを招待した自分のサーバーで、/と打ってみましょう。
  • いい感じに登録できていますね。

1

モジュールのインストール

  • 今回、packというフォルダを作成して、そのフォルダ下にpythonフォルダを作成します。そこに必要なモジュールをインストールして、最終的にpythonフォルダをzip化させます。

  • zipフォルダを作成して、最終的にLambdaレイヤーに乗せるzipは、そこに置きたいと思います。

  • まずはpackフォルダとzipフォルダを作成
mkdir pack zip
  • packフォルダに移動
cd pack
  • pythnフォルダを作成
mkdir python
  • 必要なモジュールをpythonフォルダにインストール
 pip install boto3 discord_interactions requests pykakasi Pillow -t ./python
  • pythonフォルダをzipフォルダにzip化
 zip -r ../zip/python.zip python
  • こんな感じでzip化できてます

lambda_entrypoint.pyの作成

役割

  • Discordのスラッシュコマンドからの通知を受け取ります。
  • 通知が、正式なDiscord Botからのものであるかどうか、認証します。
  • 絵文字を生成するLambda関数を非同期で呼び出します。

手順

  • lambda_entrypoint.pyを作成します。
lambda_entrypoint.py
lambda_entrypoint.py
import json
from discord_interactions import verify_key, InteractionType, InteractionResponseType
import os
import boto3

# 環境変数からDiscordの公開鍵を取得
public_key = os.getenv("PUBLIC_KEY")
# 認証が終わった後に呼び出すメインのlambda
lambda_main = os.environ.get("LONG_PROCESS_FUNCTION_NAME")

def lambda_handler(event, context):

    headers = event.get("headers", {})
    # リクエストボディをJSON形式に変換
    request_body = json.loads(event.get("body", "{}"))

    # リクエストの署名検証用のヘッダーを取得
    signature = headers.get("x-signature-ed25519")
    timestamp = headers.get("x-signature-timestamp")
    raw_body = event.get("body", "{}").encode()


    # 署名またはタイムスタンプが欠如している場合の処理
    if not signature or not timestamp:
        return {
            'statusCode': 400,
            'body': 'Bad Request: Missing signature or timestamp'
        }

    # 署名の検証
    if verify_key(raw_body, signature, timestamp, public_key):
        print('Signature is valid')
    else:
        # 署名検証に失敗した場合の処理
        print(f'Signature verification failed:')
        print(f'Raw body: {raw_body}')
        print(f'Signature: {signature}')
        print(f'Timestamp: {timestamp}')
        return {
            'statusCode': 401,
            'body': 'Unauthorized'
        }

    # インタラクションの種類を取得
    interaction_type = request_body.get("type")

    # コマンドまたはメッセージコンポーネントの場合の処理
    if interaction_type in [InteractionType.APPLICATION_COMMAND, InteractionType.MESSAGE_COMPONENT]:
        data = request_body.get("data", {})
        command_name = data.get("name")

        # コマンドが'emoji'の場合の処理
        if command_name == 'emoji':
            # 今処理中で、すぐに結果を返せないが、処理が終わったらメッセージを送るよ!
            response_data = {
                "type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
            }
            
            # デバッグ用にペイロードを表示
            print("Payload to second lambda:", json.dumps(request_body))

            # 絵文字を生成してくれるlambdaを非同期で呼び出す
            client = boto3.client("lambda")
            client.invoke(
                FunctionName=lambda_main,
                InvocationType="Event",
                Payload=json.dumps(request_body)
            )
        else:
            # 未実装のコマンドの場合の例外処理
            return {
                'statusCode': 400,
                'body': json.dumps({
                    'error': f"Command '{command_name}' is not implemented."
                })
            }
    else:
        # PINGリクエストの場合のレスポンス
        response_data = {"type": InteractionResponseType.PONG}

    # レスポンスデータを出力
    print(response_data)
    return {
        'statusCode': 200,
        'body': json.dumps(response_data)
    }
  • zipフォルダ下にzip化します
 zip -r ./zip/entrypoint.zip lambda_entrypoint.py 
  • ローカルにダウンロードします。

createImage.pyの作成

  • スタンプを生成して、Discordに返すコードです。
  • 好きな日本語のフォントを以下のサイトからダウンロードして配置します。
  • create_stamp関数の中に、フォントパスを記載するところがあるので、自分の選んだフォント名に変えてください。

https://fonts.google.com/?subset=japanese&script=Hira

createImage.py
createImage.py
import json
import os
import re
import base64
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import requests
import pykakasi

bot_token = os.getenv("BOT_TOKEN")
app_id=os.getenv('DISCORD_APP_ID')

color_map = {
    "red": (255, 0, 0),
    "green": (0, 255, 0),
    "blue": (0, 0, 255),
    "black": (0, 0, 0),
    "white": (255, 255, 255),
    "yellow": (255, 255, 0),
    "cyan": (0, 255, 255),
    "magenta": (255, 0, 255),
    "gray": (128, 128, 128),
}

# フォントサイズを決定する関数
def calculate_font_size(text_length):
    if text_length <= 3:
        return 200
    else:
        return 90

# テキストを自動改行する関数
def wrap_text(text, text_count):
    if text_count <= 3:
        return text
    elif text_count == 4:
        return text[:2] + "\n" + text[2:]
    elif text_count == 5 or text_count == 6:
        return text[:3] + "\n" + text[3:]
    elif text_count == 7 or text_count == 8:
        return text[:4] + "\n" + text[4:]
    elif text_count >= 9:
        return text[:5] + "\n" + text[5:]

# スタンプの名前(filename)を作成する関数
def create_filename(text, color):
    try:
        kakasi = pykakasi.kakasi()
    except Exception as e:
        print(f"Error creating kakasi instance: {e}")
    text_romaji = kakasi.convert(text)
    text_romaji = ''.join([word['hepburn'] for word in text_romaji])
    
    color_name = [name for name, rgb in color_map.items() if rgb == color][0]
    file_name = f"{text_romaji}_{color_name}".replace(" ", "_")
    file_name = re.sub(r'[^a-zA-Z0-9_]', '', file_name)
    print(file_name)
    return file_name

# スタンプ画像を生成する関数
def create_stamp(text, color):
    width, height = 200, 200
    text_color = color
    font_path = "./NotoSansJP-ExtraBold.ttf"  # 好きなフォント名に
    print("Creating stamp image...")

    text_count = len(text)
    
    if text_count > 11:
        raise ValueError("テキストが長すぎます。10文字以内にしてください。")
    
    background_color = (255, 255, 255, 0)  # 基本色は透明
    if text_color == (0, 0, 0):  # 文字が黒の場合は、白の背景
        background_color = (255, 255, 255, 255)
    
    if text_count == 2 or text_count == 7 or text_count == 8:
        width = height * 2
    elif text_count == 3:
        width = int(height * 3)
    elif text_count == 5 or text_count == 6:
        width = int(height * 1.5)
    elif text_count == 9 or text_count == 10:
        width = int(height * 2.5)
    
    print(f"Image size: {width}x{height}")
    
    image = Image.new('RGBA', (width, height), background_color)
    draw = ImageDraw.Draw(image)
    
    wrapped_text = wrap_text(text, text_count)
    font_size = calculate_font_size(text_count)
    print("Calculating font size...")
    font = ImageFont.truetype(font_path, int(font_size))
    print(f"Font loaded: {font}")

    bbox = draw.textbbox((0, 0), wrapped_text, font=font)
    text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
    text_x = (width - text_width) / 2
    text_y = (height - text_height) / 2
    
    draw.text((text_x, text_y - bbox[1]), wrapped_text, fill=text_color, font=font)
    
    image = image.resize((400, 400), Image.LANCZOS)

    file_name = create_filename(text, color)
    print(f"Image created with filename: {file_name}")
    
    return image, file_name

# スタンプ画像を削除する関数
def delete_stamp(emoji_id, guild_id):
    DISCORD_API_URL_DELETE = f'https://discord.com/api/v9/guilds/{guild_id}/emojis/{emoji_id}'

    headers = {
        "Authorization": f"Bot {bot_token}",
        "Content-Type": "application/json",
        "User-Agent": "DiscordBot (private use) Python-urllib/3.11"
    }

    response = requests.delete(DISCORD_API_URL_DELETE, headers=headers)
    print(f"Delete response: {response.status_code} {response.text}")
    return response

# 画像をDiscordに送信する関数
def confirm_emoji_to_discord(image, interaction_token):
    DISCORD_API_URL_SEND_IMAGE = f"https://discord.com/api/v10/webhooks/{app_id}/{interaction_token}"

    buffered = BytesIO()
    image.save(buffered, format="PNG")
    buffered.seek(0)
    
    files = {'file': ("image.png", buffered, 'image/png')}
    headers = {
        "User-Agent": "DiscordBot (private use) Python-urllib/3.11"
    }
    
    response = requests.post(DISCORD_API_URL_SEND_IMAGE, headers=headers, files=files)
    print(f"Confirm image response: {response.status_code} {response.text}")
    return response


# 画像をDiscordにアップロードする関数
def upload_emoji_to_discord(name, image,guild_id):
    DISCORD_API_URL_CREATE = f'https://discord.com/api/v9/guilds/{guild_id}/emojis'
    
    upload_image_copy = image.copy()
    buffered = BytesIO()
    upload_image_copy.save(buffered, format="PNG")
    img_str = base64.b64encode(buffered.getvalue()).decode()

    headers = {
        "Authorization": f"Bot {bot_token}",
        "Content-Type": "application/json", 
        "User-Agent": "DiscordBot (private use) Python-urllib/3.11"
    }

    payload = {
        "name": name,
        "image": f"data:image/png;base64,{img_str}",
        "roles": []
    }

    response = requests.post(DISCORD_API_URL_CREATE, headers=headers, json=payload)
    print(f"Upload emoji response: {response.status_code} {response.text}")
    return response

# コメントをDiscordに送信する関数
def comment_to_discord(text, interaction_token):
    DISCORD_API_URL_SEND = f"https://discord.com/api/v10/webhooks/{app_id}/{interaction_token}"
    
    headers = {
        "Content-Type": "application/json",
        "User-Agent": "DiscordBot (private use) Python-urllib/3.11"
    }
    
    payload = {
        "content": text
    }
    
    response = requests.post(DISCORD_API_URL_SEND, headers=headers, json=payload)
    print(f"Comment response: {response.status_code} {response.text}")
    return response


def lambda_handler(event, context):
    body = event

    print("Parsed body:", json.dumps(body, indent=2))

    command = body.get('data', {}).get('name')
    options = body.get('data', {}).get('options', [])
    interaction_token = body.get('token')
    guild_id = body.get('guild_id')

    try:
        if command == 'emoji':
            sub_command = options[0]['name']
            text = next(opt['value'] for opt in options[0]['options'] if opt['name'] == 'text')
            color = next((opt['value'] for opt in options[0]['options'] if opt['name'] == 'color'), 'black')
        
            if sub_command == 'create':
                # カラーが存在することを確認し、存在しない場合はデフォルト値を使用
                if color not in color_map:
                    color = 'black'

                image, emoji_name = create_stamp(text, color_map[color])
                response = upload_emoji_to_discord(emoji_name, image,guild_id)
                if response.status_code == 201:
                    comment_to_discord("生成成功!",interaction_token)
                    confirm_emoji_to_discord(image, interaction_token)
                else:
                    comment_to_discord("生成失敗...", interaction_token)

            elif sub_command == 'delete':
                emoji_id = text
                response = delete_stamp(emoji_id, guild_id)
                if response.status_code == 204:
                    comment_to_discord("削除成功!", interaction_token)
                else:
                    comment_to_discord("削除失敗...", interaction_token)
        else:
            print("コマンドが不明")
            comment_to_discord("コマンドが不明...", interaction_token)
    except Exception as e:
        print('Exception occurred:', str(e))
        comment_to_discord("エラーが発生しました...", interaction_token)
        result = {'statusCode': 500, 'body': str(e)}
    return {
        'statusCode': 200,
        'body': '成功'
    }
  • zipフォルダ下にzip化します
  • NotoSansJP-ExtraBold.ttfの部分は、選んだフォントのファイル名に変えてください
zip -r  ./zip/emoji-create.zip createImage.py NotoSansJP-ExtraBold.ttf
  • ローカルにダウンロードします。

AWSにデプロイ

  • ついに必要なzipファイルが出そろいました!

Lambdaレイヤーの作成

  • Lambdaレイヤーは、Lambda関数で使用するモジュールとその他の依存関係をパッケージ化し、Lambda関数間で共有可能にする機能です。
  • 「Lambda > その他リソース > レイヤー」 で、レイヤーを作成します。
  • レイヤー名:emoji-discord-bot-layer
  • 先ほど作成したpython.zipをアップロードします。
  • ランタイムをpython3.11にします。

IAMポリシーの作成

  • このポリシーは、Lambda関数が、他のLambda関数を呼び出すことを許可するポリシーになります。
  • IAMを開きます。
  • ポリシーエディタでJSONを選択
  • 名前:emoji-lambda-policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "lambda:InvokeFunction",
            "Resource": "arn:aws:lambda:ap-northeast-1:アカウントID:function:*"
        }
    ]
}

IAMロールの作成

  • Lambda(entorypoint-enoji)に付与するIAMロールを作成します。

  • このLambdaは、絵文字を作成するLambda(emoji-create)を呼び出さないといけないので、さっき作ったポリシーを付与します。

  • 名前:entorypoint-emoji-role

  • 許可ポリシー

    • AWSLambdaBasicExecutionRole
    • さっき作ったポリシー(emoji-lambda-policy)
  • Lambda(emoji-create)に付与するIAMロールを作成します。

  • 名前:emoji-lambda-role

  • 許可ポリシー:AWSLambdaBasicExecutionRole

Lambda(entorypoint-enoji)関数の作成

関数の作成

  • Lambda関数を作成します。
  • 関数名を「entorypoint-enoji」とします。
  • ロールで、作ほど作ったentorypoint-emoji-roleを指定

  • 詳細設定を開き、関数URLを有効化します
  • 認証タイプをNONEにします
  • 作成ボタンを押して作成します

  • 作成した関数(entorypoint-enoji)を開き、コードを選択します。

zipファイルをアップロード

  • アップロード元を押して、zipファイルを選択します。
  • entrypoint.zipをアップロードします。

ランタイムのハンドラを編集

  • ランタイム設定のハンドラを編集します。
  • 「ファイル名.実行したい関数名」とするので、今回の場合はlambda_entrypoint.lambda_handlerとします。

Lambdaレイヤーを設定

  • レイヤーを編集します。
  • カスタムレイヤーを選択し、先ほど作成したレイヤーを指定します。

環境変数を設定

  • 次に環境変数を設定します。
  • 設定を選択し、環境変数を編集します。
    • LONG_PROCESS_FUNCTION_NAME:emoji-create(非同期で呼び出す予定のlambda関数名)
    • PUBLIC_KEY:先ほどコピーしたPUBLIC_KEY

メモリとタイムアウトを編集

  • 設定からメモリとタイムアウトを編集します。
  • メモリを300MBにする
  • タイムアウトを3分に設定

Lambda(emoji-create)

関数の作成

  • 関数名を「emoji-create」とします。
  • ロールで、作ほど作ったemoji-lambda-roleを指定
  • 作成

zipファイルをアップロード

  • アップロード元を押して、zipファイルを選択します。
  • emoji-create.zipをアップロードします。

ランタイムのハンドラを編集

  • ランタイム設定のハンドラを編集します。
  • 「ファイル名.実行したい関数名」とするので、今回の場合はcreateImage.lambda_handlerとします。

Lambdaレイヤーを設定

  • レイヤーを編集します。
  • カスタムレイヤーを選択し、先ほど作成したレイヤーを指定します。

環境変数を設定

  • 次に環境変数を設定します。
  • 設定を選択し、環境変数を編集します。
    • BOT_TOKEN:前にコピーしたTokenを設定
    • DISCORD_APP_ID:前にコピーしたAPPLICATION IDを設定

メモリとタイムアウトを編集

  • 設定からメモリとタイムアウトを編集します。
  • メモリを300MBにする
  • タイムアウトを3分に設定

Interaction URL を設定する

  • ようやくデプロイが完成しました!

  • もう少しです!!!!!!!

  • Lambda(entorypoint-enoji)関数を開きます。

  • 設定から関数URLを開きます。

  • 次に使うのでコピーしておきます。

  • 以下のリンクから、Discord Botのページに戻ります。

https://discord.com/developers/applications

  • Interaction Endpoint URLに、先ほどコピーしたLambda関数のURLをセットします。

  • 終わり!!!!

まとめ

  • 今回、コードについて詳しく説明できなかったので、また違う記事で解説したいと思います。
  • 作るのに1週間以上かかりました。

参考文献

  • すばらしい記事でした...。感謝。

https://blog.shikoan.com/discord-bot-lambda-1/

https://blog.shikoan.com/discord-bot-lambda-2/

https://dev.classmethod.jp/articles/discord-application-commands-aws-lambda-aws-cdk/

MidraLab(ミドラボ)

Discussion