スタンプを自動で作って登録してくれるDiscord Botを作ってみた!(Lambda&Python)
はじめに
- Discordで作ってほしいスタンプの文字を打つと、そのスタンプを作って、Discordの絵文字として追加してくれるボットが欲しいなと思って、作り始めました!
- サーバー代を安くするためにLambdaを利用しています。
- こんな感じのスタンプを作ります!
作ったもの
-
/
と打ちます。 -
/emoji create
と/emoji delete
を選択することができます。
- text項目に、スタンプとして作りたい文字を入力します。
- color項目で、スタンプの色を選択します。
- スタンプを作成し、Discordにスタンプを送ってくれます。
- さらに、自動でDiscordに絵文字を登録してくれます。
- スタンプの名前は、
:スタンプのローマ字_色:
で登録されます。
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を使用してスタンプを登録する
最終フォルダ構成
作り方
Discord Botの作成
- 以下のリンクからBotを作成できます。
-
New Aplication
を押します。
-
ボットの名前を決めて
create
を押します。 -
愛着がわくように、可愛いアイコンを登録しましょう
-
APPLICATION ID
とPUBLIC KEY
は後で使うので、メモ帳にコピーしておきます(いつでも見ることができるので忘れたら見に戻りましょう)。
-
Bot
を選択します。 -
TOKEN
に、Reset Token
というボタンがあるので押します。 - Tokenを発行して、コピーしておきます。
サーバーにBotを招待するリンクを作成
-
OAuth2
を選択します -
SCOPES
の中にある、applications.commands
とbot
にチェックを入れます。
-
さらに、その下の
BOT PERMISSIONS
の中にある、Manage Expressions
にチェックを入れます。 -
絵文字の追加や削除の許可を行っています。
-
一番下に、
GENERATED URL
でURLが作成されていると思うので、そのリンクを取得します。 -
このリンクが、ボットを招待できるリンクになります。もし、たくさんの人にボットを公開したい場合、このリンクを配布してボットを追加してもらいます。
-
このリンクを使って、自分のDiscordサーバーにもボットを追加しておきましょう。
-
招待できましたか?自分のBotは、可愛いですね。。
環境構築
- AWS Cloud9を利用して作成しました。
- 以下のサイトを参考にCloud9にPython3.11のインストールします!
スラッシュコマンドの作成
- 環境構築できたので、先ほど作ったボットにスラッシュコマンドを登録しましょう!
- このコードは、一回実行するだけで、コマンドとしてボットに登録されるので、ぱっと登録しておきましょう。
- 以下のドキュメントを参考にスラッシュコマンドを登録するPythonコードを作成しました。
- また、今度の記事でコードを解説したいと思います。
-
requests
をインストールします。
pip install requests
- register_commands.pyを作成します。
-
application_id
とbot_token
には、先ほどコピーした値を入れて下さい。
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を招待した自分のサーバーで、
/
と打ってみましょう。 - いい感じに登録できていますね。
モジュールのインストール
-
今回、
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
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関数の中に、フォントパスを記載するところがあるので、自分の選んだフォント名に変えてください。
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のページに戻ります。
- Interaction Endpoint URLに、先ほどコピーしたLambda関数のURLをセットします。
- 終わり!!!!
まとめ
- 今回、コードについて詳しく説明できなかったので、また違う記事で解説したいと思います。
- 作るのに1週間以上かかりました。
参考文献
- すばらしい記事でした...。感謝。
midra-lab.notion.site/MidraLab-dd08b86fba4e4041a14e09a1d36f36ae 個人が興味を持ったこと × チームで面白いものや興味を持ったものを試していくコミュニティ
Discussion