DjangoにWeb会議を追加する(Amazon Chime)
最近はコロナの影響で、ZOOM会議が流行ったりして、Web会議を行うのが一般的になってきましたね。
会社でも、打ち合わせをするときにオンラインで会議をしたり、リモートで仕事をするときにこうしたWeb会議システムで参加することが多いですよね。
そこで今回は、Amazon Chimeを利用して、DjangoのWebアプリケーションにWeb会議機能を実装する方法について紹介します。
Amazon Chimeには、1対1の通話や、多人数が参加できる会議、チャット機能から画面共有機能まで。Web会議に必要な機能が一通り揃っています。
では、さっそくAmazon Chimeを使っていきましょう!
パッケージのインストール
DjangoからAmazon Chimeを使うには、4つのパッケージが必要になります。django、boto3、awscli、Amazon Chime SDK for javascriptです。
boto3は、AWS SDK for Pythonと呼ばれるもので、要するにAWSにアクセスしたい時に使う、Python用のSDKですね(SDK:Software Development Kit)。名前の由来がよくわからないので、とても覚えにくいです(´;ω;`)
そして、awscliはAWS Command Line Interfaceの略で、AWSにコマンドラインからアクセスできるようにするツールです。
この2つを連携させることで、Amazon Chimeを使うことができるようになります。
最後に、Amazon Chime SDK for JavaScriptですが、ブラウザでWeb会議画面を表示したりするのに使っていきます。このパッケージを使うために、Node.jsとnpmも必要になってきます。
…というわけで、さっそくPythonの仮想環境に入って、これらのパッケージをインストールしていきます。
pip install django
pip install boto3
pip install awscli
ここまではいつも通りですが、amazon-chime-sdk-jsを使うために、node.jsが必要になります。
macOSの場合、Homebrewをインストールしていない場合は、まずそこから始める必要があります。
# Homebrewのインストール
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# node.js/npmのインストール
brew install node
Amazon Chime SDK for JavaScriptの準備
Amazon Chime SDK for JavaScriptは以下のGitHubからcloneして導入します。
この中の/demos/singlejs/にある手順を使って、Amazon Chime SDK for JavaScriptを1つのjsファイルにまとめあげます。この手順のためにnode.jsが必要になります。
ここにある手順には、以下のコマンドでできると書いてありますが、僕の場合はうまくいきませんでした。
git clone https://github.com/aws/amazon-chime-sdk-js.git
cd amazon-chime-sdk-js/demos/singlejs
npm run bundle
以下の手順を追加したら成功するようになりました。
git clone https://github.com/aws/amazon-chime-sdk-js.git
cd amazon-chime-sdk-js/demos/singlejs
npm init --yes
npm install express ejs nodemon
npm run bundle
普段node.js使っていないのがバレる_(┐「ε:)_
これで、buildフォルダにamazon-chime-sdk.min.jsとamazon-chime-sdk.min.js.mapのファイルが作成されます。これらのファイルは後で利用します。
まずは、AWS側の準備も行いましょう。
AWSでIAMユーザーの作成
続いて、Amazon Chimeで利用するIAMユーザーを作成します。
IAM(Identity and Access Management)は、AWSでユーザー作成やその権限などを設定できるサービスです。AWSアカウントを作成すると、ルートユーザーと呼ばれる、全ての権限をもったユーザーが作成されます。ただ、そのルートユーザーでなんでもできてしまう、というのが問題になることがあります。
ルートユーザーが乗っ取られた場合、悪意を持った人がなんでもできてしまうかもしれません。アカウントを共有していたら、誰が何を変更したのかわからない状態になります。
このような状態を避けるために、それぞれの用途に合わせてIAMユーザーを作成し、各ユーザーにそれぞれ必要な必要最低限の権限を与えることで、もしもの場合に備えておくことができます。
今回はAmazon Chimeだけを使うので、Amazon Chimeしか利用できないユーザーを作成するというわけですね!
まずは、AWSマネジメントコンソールでIAMサービスを選択し、左側のメニューから[ユーザー]を選択します。
画面上部にある[ユーザーを追加]ボタンを押して、ユーザーの作成を始めていきます。
作成画面に入ったら、適当に名前をつけてユーザーを作成します。名前はなんでもいいですが、あとでどんなユーザーかわかりやすいようにしておく方が良いでしょう。
下の、[AWSアクセスの種類を選択]に関しては、今回は[AWSマネジメントコンソールへのアクセス]は必要ないので、[プログラムによるアクセス]にだけチェックを入れます。
次に進むと、アクセス許可を設定する画面になります。
[ユーザーをグループに追加]は、同じような権限を持ったユーザーを複数作成するのであれば、グループを作成しておくと便利です。そのグループにユーザーを追加/削除するだけで、そのグループに設定した権限を与えたりなくしたりすることができます。
[アクセス権限を既存のユーザーからコピー]はそのままですね。
今回は新しく作るので、[既存のポリシーを直接アタッチ]を選択します。ポリシーはAWSのサービスの利用権限を細かくわけています。このポリシーの一覧の中から必要な権限を選んで、ユーザーに付与することができます。
Amazon Chimeを使う場合は、検索バーに"chime"と入力して、Amazon Chime関連のポリシーをフィルターしましょう。
[AmazonChimeFullAccess]にチェックを入れて次に進みます。
タグの追加は任意です。作成しているサービスごとにタグを作っておくと、一覧にしたい時に便利です。
最後に確認画面があります。
確認して問題なければ、下の方にある「ユーザーの作成」ボタンを押します。
成功すると、サインイン用のアドレスや、アクセスキーID、シークレットアクセスキーをダウンロードできる画面になります。(画像ではセキュリティのため表示しません)
これらのキーを記憶するのは困難なので、忘れずに.csvファイルをダウンロードしておきましょう。このファイルは厳重管理しておきます。
AWS CLIのインストール
boto3からAWSサービスにアクセスするためには、利用するIAMユーザーを設定しなければなりません。そのための事前設定として、awscliに先ほど作成したキーを登録する必要があります。
awscliインストール後に、以下のコマンドを入力するとセットアップが開始されます。
aws configure
すると、先ほどIAMユーザーで作成したアクセスキーIDなどを求められますので、それぞれ入力していきます。
今回、Amazon Chime SDKを使うために、リージョンは"us-east-1"にしておく必要があります。
出力フォーマットはjsonにします。
Default region name [us-west-2]:us-east-1
Default output format [None]:json
※実際に会議を行うリージョンは後で設定できます。
これでAWS側の準備は完了です。
あとは、DjangoでAmazon Chimeを使ったモデルを作っていきましょう。
Djangoのchimeアプリケーションの作成
これからchimeというアプリケーションを作成していきます。
やりたいことによってモデルは変わってくると思いますが、初めて使う時は、できるだけシンプルに作成するのが基本です。
ここで注意点として、Amazon Chime SDKで作成したミーティングは、以下の条件で削除されます。
# https://docs.aws.amazon.com/chime/latest/dg/mtgs-sdk-mtgs.html
The meetings end when you run the DeleteMeeting API action. A meeting automatically ends after a period of inactivity, such as the following:
・No audio connections are present in the meeting for more than five minutes.
・Less than two audio connections are present in the meeting for more than 30 minutes.
・Screen share viewer connections are inactive for more than 30 minutes.
・The meeting time exceeds 24 hours.
つまり、ミーティングは開催直前に作成してすぐに繋ぐようにしなければなりません。
というわけで、Meetingモデルを作成し、そこに開催日時と参加者を設定できるようにしておきましょう。そして、時間になったら最初の参加者がミーティングを作成し、他の参加者が参加可能になる…という流れで作成します。
簡単のため、ユーザーはデフォルトのユーザーモデルを利用していきます。
まずは、chimeアプリケーションを作成しましょう。
python manage.py startapp chime
chimeアプリケーションのモデル
今回はシンプルに作りたいので、モデルには最低限の情報だけつけていきます。
# chime/models.py
from django.db import models
import uuid
import os
from django.contrib.auth.models import User
class Meeting(models.Model):
token = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=50)
datetime = models.DateTimeField()
member = models.ManyToManyField(User)
response = models.JSONField(blank=True, null=True)
まずtokenにUUIDを設定します。これは、Amazon Chime SDKに渡す値で、他と被らないようにするためにUUIDを使うようにします。
nameはミーティングの名前
datetimeはミーティングの開催時間
memberはミーティングに参加する予定のUser
responseには、Amazon Chimeからの返答を格納します。
この下にもう1つモデルを追加します。Attendeeというモデルです。Amazon Chimeではミーティングに参加するためには、Attendee(参加者)を作成する必要があります。
Attendeeを追加するとその参加者ごとに、Join Tokenが作成されます。このJoin Tokenをもっているユーザーがミーティングに参加できる、という仕組みになっています。
# chime/models.py
class Attendee(models.Model):
meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
response = models.JSONField(blank=True, null=True)
meetingはAttendeeを追加したミーティングです。Attendeeはミーティングごとに作成されるので、ミーティングに紐付けます。
userは参加するユーザーです。
responseはAttendeeを作成したときにAmazon Chime SDKから送られるJoin Tokenを含むJSONです。
今回は、この2つのモデルを使っていきます。
利用するユーザーの作成
Attendeeはユーザーを使うので、ユーザーを作成する必要がありますが、面倒なので、adminサイトで作成してしまいます。
以下コマンドを入力して管理者を作成します。
python manage.py createsuperuser
適当に情報を入力して管理者を作成したら、開発用サーバーを起動してhttp://localhost:8000/admin/にアクセスしてログインします。
python manage.py runserver
ログインしたら、Usersの右側にあるAddをクリックしてユーザーを追加します。
適当に2名ほどユーザーを作成しておきます。
後でこれらのユーザーでログインして会議できるか試すことになります。
ユーザーログイン設定
作成したユーザーで、ログインとログアウトをできるようにしておきましょう。
プロジェクトのurls.pyのurlpatternsに以下を追加します。
# urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include("chime.urls")),
path('accounts/', include('django.contrib.auth.urls')),
]
これによって、以下のurlが追加されます。今回は、loginとlogoutだけ使います。
# https://docs.djangoproject.com/en/3.1/topics/auth/default/
accounts/login/ [name='login']
accounts/logout/ [name='logout']
accounts/password_change/ [name='password_change']
accounts/password_change/done/ [name='password_change_done']
accounts/password_reset/ [name='password_reset']
accounts/password_reset/done/ [name='password_reset_done']
accounts/reset/<uidb64>/<token>/ [name='password_reset_confirm']
accounts/reset/done/ [name='password_reset_complete']
また、django.contrib.authで使われているデフォルトのテンプレートを上書きするために、chimeアプリケーションのテンプレートにregistrationフォルダを作成して、login.htmlとlogged_out.htmlを作っておきます。
# chime/templates/registration/login.html
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
</tr>
</table>
<input type="submit" value="login" />
<input type="hidden" name="next" value="{{ next }}" />
</form>
# chime/templates/registration/logged_out.html
<p>ログアウトしました!</p>
<a href="{% url 'login'%}">再度ログインするにはこちらをクリック</a>
なぜこれが必要かというと、ログアウトしたときに、adminのログアウト画面に遷移してしまうので、それを避けるためにテンプレートを上書きしています。
この設定を適用するために、settings.pyのINSTALLED_APPSでchimeアプリケーションを'django.contrib.auth'より前にくるようにしてください。
デフォルトだとログインした際、accounts/profile/に遷移してしまうので、ついでにLOGIN_REDIRECT_URLをルートアドレスに変更しておきましょう。
# settings.py
LOGIN_REDIRECT_URL = "/"
INSTALLED_APPS = [
'chime.apps.ChimeConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
これらの設定を行うことで、
ログインは、localhost:8000/accounts/login/
ログアウトは、localhost:8000/accounts/logout/
でできるようになりました。
staticフォルダの設定
前の方で作成した、amazon-chime-sdk.min.jsとamazon-chime-sdk.min.js.mapを使えるようにしましょう。これらは静的ファイルなのでstaticフォルダに入れるようにします。
プロジェクトに以下のようにファイルを配置しましょう。
プロジェクトフォルダ
|- chimeフォルダ
|- ...
|- static
|- scripts
|- amazon-chime-sdk.min.js
|- amazon-chime-sdk.min.js.map
|- manage.py
Djangoにstaticファイルがどこにあるか指定するために、setting.pyで以下の変数を設定します。
# settings.py
STATIC_URL = '/static/'
STATICFILES_DIRS = [
BASE_DIR / "static",
'/var/www/static/',
]
これで後からテンプレートからamazon-chime-sdk.min.jsにアクセスできるようになりました。
chimeアプリケーションViewの実装
これでようやくchimeアプリケーションのView作成に入ることができます。長い…(^◇^;)汗
まずはurls.pyを作成して、ルーティングをしておきましょう。
from django.urls import path
from . import views
app_name = "meeting"
urlpatterns = [
path("create/", views.MeetingCreateView.as_view(),name="create"),
path("", views.MeetingListView.as_view(),name="list"),
path("<str:pk>/", views.MeetingDetailView.as_view(),name="detail"),
path("start/<str:pk>/", views.MeetingView.as_view(),name="meeting"),
]
create:ミーティングを作成
list:ミーティングの一覧
detail:ミーティングの詳細
meeting:実際にミーティングをするページ
となっています。
正直、create、list、detailは他の例と変わらないので、作り方は以下を参照してください。今回はMeetingViewに絞って解説します。
MeetingViewの実装
まずは実装したコードを紹介します。
# chime/views.py
class MeetingView(LoginRequiredMixin,generic.TemplateView):
model = Meeting
template_name = 'meeting/meeting.html'
context_object_name = 'chime'
login_url = '/accounts/login/'
redirect_field_name = 'redirect_to'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
meeting = Meeting.objects.get(pk=self.kwargs['pk'])
if meeting.response == None:
meeting.response = use_chime_meeting(meeting.token)
meeting.save()
for member in meeting.member.all():
attendee = Attendee.objects.create(meeting=meeting,user=member)
attendee.response = use_chime_attendee(meeting.response["Meeting"]["MeetingId"],member.pk)
attendee.save()
context['meeting'] = meeting
try:
context['attendee'] = Attendee.objects.get(meeting=self.kwargs['pk'],user=self.request.user)
except:
context['attendee'] = ""
return context
LoginRequiredMixinを継承することによって、このViewにはログインしたユーザーしかアクセスできないようになります。そして、ログインしていなかった場合は、login_urlに移動されます。
get_context_data(self, **kwargs)は、テンプレートに使いたい要素を追加できる関数です。今回はcontext['meeting']とcontext['attendee']を追加しており、これらをテンプレートで使うことができるようになります。
テンプレート内では{{ meeting }}や{{ attendee }}のように情報を取得できます。
下のコードの部分ですが、ここでAmazon Chime SDKにアクセスして、ミーティングの作成とAttendeeの追加を行っています。responseがまだない時=まだ誰もミーティングに参加していない状態です。
if meeting.response == None:
meeting.response = use_chime_meeting(meeting.token)
meeting.save()
for member in meeting.member.all():
attendee = Attendee.objects.create(meeting=meeting,user=member)
attendee.response = use_chime_attendee(meeting.response["Meeting"]["MeetingId"],member.pk)
attendee.save()
作成したらresponseを受け取ってデータベースに保存しておきます。ミーティングの主催者を設定して、主催者しかできないようにしてもいいですね!
use_chime_meetingとuse_chime_attendeeは以下のようになっています。
# chime/views.py
def use_chime_meeting(token):
# create session
session = Session(profile_name="default")
chime = session.client("chime")
response = chime.create_meeting(
ClientRequestToken=str(token),
MediaRegion='us-east-1'
)
return response
create_meetingでミーティングを作成できます。
ClientRequestTokenはモデルのUUIDを使って、被らないようにしています。
MediaRegionはミーティングを開催するリージョンです。us-east-1にしていますが、近いリージョンを使う方が遅延がなくなる可能性が高いです。
使えるMediaRegionは以下参照。
# chime/views.py
def use_chime_attendee(meeting_id,user_id):
# create session
session = Session(profile_name="default")
chime = session.client("chime")
response = chime.create_attendee(
MeetingId=str(meeting_id),
ExternalUserId="user"+str(user_id),
)
return response
こちらでは、先ほどuse_chime_meetingで取得したresponseからMeetingIDを抽出して渡しています。ExternalUserIdはユーザー毎に違う値を与えるようにします。今回はデフォルトのユーザーモデルなのでidを使っていますが、UUIDにしておきたいですね。
最後に以下の部分を説明しておくと、self.request.userはログインしているユーザー自体を取得できます。つまり、参加したいミーティングに自分がAttendeeとして登録されているなら、Attendee情報を取得できて、会議に参加できるというわけです。
context['attendee'] = Attendee.objects.get(meeting=self.kwargs['pk'],user=self.request.user)
MeetingViewのテンプレート
これが今回、一番難解なところかと思います。amazon-chime-sdk.min.jsを使って、ミーティングに接続していきます。まずはコードをご覧ください。
こちらの記事を参考にさせていただきました。
# templates/meeting/meeting.html
{% load static %}
{% load replace %}
<!DOCTYPE html>
<html>
<head>
<script src="{% static 'scripts/amazon-chime-sdk.min.js' %}"></script>
</head>
<body>
<div>
<div id="video-tile">
<video id="video-tile-self"></video>
</div>
<audio id="audio-view"></audio>
</div>
</body>
<script>
(async () => {
const meeting = JSON.parse(`{{ meeting.response|replace1|safe }}`);
const attendee = JSON.parse(`{{ attendee.response|replace2|safe }}`);
const logger = new ChimeSDK.ConsoleLogger('ChimeMeetingLogs', ChimeSDK.LogLevel.DEBUG);
const deviceController = new ChimeSDK.DefaultDeviceController(logger);
const configuration = new ChimeSDK.MeetingSessionConfiguration(meeting,attendee);
const meetingSession = new ChimeSDK.DefaultMeetingSession(configuration, logger, deviceController);
try {
const audioInputs = await meetingSession.audioVideo.listAudioInputDevices();
const audioOutputs = await meetingSession.audioVideo.listAudioOutputDevices();
const videoInputs = await meetingSession.audioVideo.listVideoInputDevices();
await meetingSession.audioVideo.chooseAudioInputDevice(audioInputs[0].deviceId);
await meetingSession.audioVideo.chooseAudioOutputDevice(audioOutputs[0].deviceId);
await meetingSession.audioVideo.chooseVideoInputDevice(videoInputs[0].deviceId);
} catch (err) {
// handle error - unable to acquire audio device perhaps due to permissions blocking
}
const audioOutputElement = document.getElementById('audio-view');
meetingSession.audioVideo.bindAudioElement(audioOutputElement);
const videoElementSelf = document.getElementById('video-tile-self');
const videoElementTile = document.getElementById('video-tile');
const observer = {
videoTileDidUpdate: tileState => {
if (tileState.localTile){
meetingSession.audioVideo.bindVideoElement(tileState.tileId, videoElementSelf);
}else{
if(!document.getElementById(tileState.tileId)){
const node = document.createElement("video");
node.id = tileState.tileId;
videoElementTile.appendChild(node);
}
const videoElementNew = document.getElementById(tileState.tileId);
meetingSession.audioVideo.bindVideoElement(tileState.tileId, videoElementNew);
}
},
videoTileWasRemoved: tileId => {
if(document.getElementById(tileId)){
const videoElementRemoved = document.getElementById(tileId);
videoElementRemoved.remove();
}
}
};
meetingSession.audioVideo.addObserver(observer);
meetingSession.audioVideo.startLocalVideoTile();
meetingSession.audioVideo.start();
})();
</script>
</html>
staticファイルを利用するには、{% load static %}を使って、staticタグを使えるようにします。そして、使いたいファイルは、{% static 'scripts/amazon-chime-sdk.min.js' %}のようにアクセスできるようになります。
以下がこのファイルのメインとなる部分です。
<div id="video-tile">
<video id="video-tile-self"></video>
</div>
<audio id="audio-view"></audio>
video-tileには、ミーティングに参加している各ユーザーの映像を追加していきます。下のスクリプトで、参加しているユーザー毎に<video>タグを追加していきます。video-tile-selfは自分の映像を表示する場所です。
あとは、audioタグで音声が聞こえるようにしていきます。
最後にスクリプトの説明です。
まず、{{ meeting.response|replace1|safe }}では、responseの内容をjsに渡しています。replace1はちょっと雑ですが、JSON内のMeeting部分を取り出して、'を"に変換しています。templatetags内に書いているので気になる方はそちらを見てください。safeは"を"とかに変換されないようにしてくれます。
下がAmazon Chime SDK for Javascriptの初期設定部分です。
const logger = new ChimeSDK.ConsoleLogger('ChimeMeetingLogs', ChimeSDK.LogLevel.DEBUG);
const deviceController = new ChimeSDK.DefaultDeviceController(logger);
const configuration = new ChimeSDK.MeetingSessionConfiguration(meeting,attendee);
const meetingSession = new ChimeSDK.DefaultMeetingSession(configuration, logger, deviceController);
次に、マイクやスピーカー、カメラの情報を取得して、最初に取得したものを使うようにしています。リスト化してユーザーに設定させることもできますが、今回はしません。
try {
const audioInputs = await meetingSession.audioVideo.listAudioInputDevices();
const audioOutputs = await meetingSession.audioVideo.listAudioOutputDevices();
const videoInputs = await meetingSession.audioVideo.listVideoInputDevices();
await meetingSession.audioVideo.chooseAudioInputDevice(audioInputs[0].deviceId);
await meetingSession.audioVideo.chooseAudioOutputDevice(audioOutputs[0].deviceId);
await meetingSession.audioVideo.chooseVideoInputDevice(videoInputs[0].deviceId);
} catch (err) {
// handle error - unable to acquire audio device perhaps due to permissions blocking
}
audioタグから音声が出るように設定します。
const audioOutputElement = document.getElementById('audio-view');
meetingSession.audioVideo.bindAudioElement(audioOutputElement);
こちらが、videoタグでどのような動きをするか定義しています。tileState.tileIDでそれぞれのユーザー用のタグを追加しています。
const videoElementSelf = document.getElementById('video-tile-self');
const videoElementTile = document.getElementById('video-tile');
const observer = {
videoTileDidUpdate: tileState => {
if (tileState.localTile){
meetingSession.audioVideo.bindVideoElement(tileState.tileId, videoElementSelf);
}else{
if(!document.getElementById(tileState.tileId)){
const node = document.createElement("video");
node.id = tileState.tileId;
videoElementTile.appendChild(node);
}
const videoElementNew = document.getElementById(tileState.tileId);
meetingSession.audioVideo.bindVideoElement(tileState.tileId, videoElementNew);
}
},
videoTileWasRemoved: tileId => {
if(document.getElementById(tileId)){
const videoElementRemoved = document.getElementById(tileId);
videoElementRemoved.remove();
}
}
};
meetingSession.audioVideo.addObserver(observer);
最後に、自分の映像を表示するようにして、ミーティングを開始しています。
meetingSession.audioVideo.startLocalVideoTile();
meetingSession.audioVideo.start();
これで全ての実装が終わりました!
さらに詳細な設定を知りたい方は、下を読んでみてください。この記事を読んだあとなら、かなりわかりやすくなっていると思います!
動かしてみる
まずは、http://localhost:8000/accounts/login/にアクセスしてログインしておきます。
それからミーティングを作成します。
※Memberはshiftを押しながらクリックすれば複数選択できます。
ミーティングのDetailページに入るとこのように作成されています。responseはまだありませんね。
これでミーティングを始めるをクリックすればミーティングが開始されます。見られたくないところを隠しているのでアレですが、2拠点で参加してみて以下のようになりました。
サンプルサイト
GitHubにここまでに作成した内容を残しておきます。
※repl.itだと、どうせ実行できないので…。゚(゚´Д`゚)゚。
ここまで読んでいただけたなら、”スキ”ボタンを押していただけると励みになります!(*´ー`*)ワクワク
この記事が気に入ったらサポートをしてみませんか?