グローバルなデータ保護およびプライバシー規制により、個人は自分のデータを制御する権利が付与されており、削除を要求する権利(一般に「削除の権利」または「削除する権利」と呼ばれます)を含みます。ユーザーIDなどの個人データまたは**個人を特定できる情報(PII)**を保存している場合、ユーザーからの要求を受け取った際にこの情報を永久に削除することが求められます。詳細については、RTBFとクリエイターを参照してください。
要求を手動で処理するのではなく、ウェブフックを設定し、サードパーティのメッセージアプリケーション内でボットを使用してプロセスを自動化することができます。PIIデータを保存する最も一般的な方法であるデータストアを利用し、このチュートリアルでは、削除要求を自動化する解決策として、ユーザーが参加したゲームのOpen Cloud API for data storesを使用してPIIデータを削除するDiscord内のボットを作成する方法の例を提供します。
ワークフロー
このチュートリアルを完了すると、ユーザーからの削除要求を自動的に処理するローカルで実行されるカスタムプログラムを作成できるようになります。このプロセスのワークフローは次のとおりです:
- Robloxサポートは、ユーザーから削除要求を受け取ります。
- Robloxのウェブフックがトリガーされ、ペイロードにはユーザーIDと、ユーザーが参加しているゲームのスタートプレイスIDのリストが含まれます。
- あなたのボットはこれらのウェブフック通知を待ち受け、その真正性を確認し、Open Cloud API for data storesを利用してデータストアに保存されているPIIデータを削除します。
- ボットはDiscord内のウェブフックメッセージに対し、削除のステータスを返信します。

サードパーティ統合でのウェブフックの構成
ボットを作成する前に、サードパーティのメッセージアプリケーションでウェブフック統合を持つサーバーを設定します。その後、そのサーバーを使用してCreator Dashboardでウェブフックを構成します。
サーバーを設定する
以下の手順は、Discordを使用してサーバーを設定する方法を示しています。
新しいDiscordサーバーを作成します。プロセスに不明な点がある場合は、Discordサポートを参照してください。
サーバーは自動的に**#generalチャネルをデフォルトチャネルとして作成します。#generalチャネルのチャネルを編集**アイコンをクリックします。
権限の下で、チャネルをプライベートに設定します。
新しいサーバーでウェブフック統合を作成し、それにRTBF Hookという名前を付けます。プロセスに不明な点がある場合は、Discordサポートを参照してください。
ウェブフックURLをコピーし、安全な場所に保存します。このURLは信頼できるチームメンバーのみがアクセスできるようにしてください。URLが漏れると、悪意のあるユーザーが偽のメッセージを送信し、あなたのユーザーデータを削除する可能性があります。
Robloxでのウェブフックの構成
サードパーティのサーバーURLを取得したら、それを使用してCreator Dashboardでウェブフックを構成します。次の設定を行うことを確認してください:
- DiscordサーバーURLをWebhook URLとして追加します。
- カスタムSecretを含めます。秘密は構成を完了させるためにオプションですが、悪意のあるユーザーがRobloxになりすましてデータを削除するのを防ぐために、含めるべきです。秘密の使用に関する詳細は、ウェブフックのセキュリティを検証を参照してください。
- トリガーの下で削除要求を選択します。
テスト応答ボタンを使用してウェブフックをテストし、Robloxから自分のサーバーの**#general**チャネルに通知が届くか確認できます。通知が届かない場合は、再度お試しいただくか、サーバー設定を確認してエラーをトラブルシュートしてください。
ボットの構成
ウェブフックを追加した後、以下の手順でボットを構成します。詳細については、Discordドキュメンテーションを参照してください。
- アプリケーションページに移動します。
- 新しいアプリケーションを作成し、RTBF Botという名前を付けます。
- システムはボットの一般情報設定にリダイレクトします。アプリケーションIDをコピーして安全な場所に保存します。
- 設定メニューの下でOAuth2を選択します。
- OAuth2設定に移動します。
- ボットスコープを有効にすると、追加のボット権限のリストが表示されます。
- 管理者権限を追加します。変更を保存します。
- 生成されたURLを保存します。
- 生成されたURLをコピーします。アプリケーション設定ページは閉じないでください。
- 生成されたURLに移動します。対象サーバーを選択します。続行ボタンをクリックし、次に認証ボタンをクリックします。
- アプリケーション設定ページに戻り、ボット設定に移動します。
- 特権ゲートウェイインテントセクションで、メッセージコンテンツインテントを有効にします。
- ボット設定 > ボット構築セクションで、後のステップで使用するためにボットトークンを安全な場所に保存します。トークンが表示されない場合は、トークンのリセットボタンをクリックして新しいトークンを生成してください。
Open Cloud APIキーの作成
サードパーティボットがユーザーのPIIデータを保存するためのデータストアにアクセスできるように、Open Cloud APIキーを作成し、データ削除のためのデータストアのエントリの削除権限を追加します。PIIを保存するために順序付きデータストアを使用している場合は、順序付きデータストアの書き込み権限も追加する必要があります。完了したら、APIキーをコピーして安全な場所に保存し、後のステップで使用します。
ゲームとプレイスの識別子を取得する
ボットがユーザーによる削除要求のためにPIIデータを見つけるために、ボットを使用する予定のすべてのゲームの以下の識別子を取得します:
- ゲームの一意の識別子であるユニバースID。
- ゲームのスタートプレイスの一意の識別子であるスタートプレイスID。
これらの識別子を取得するには:
Creator Dashboardに移動します。
ゲームのサムネイルの上にカーソルを合わせ、⋯ボタンをクリックし、それぞれユニバースIDをコピーとスタートプレイスIDをコピーを選択します。

スクリプトの追加
ウェブフック、ボット、およびデータストア用のAPIキーの設定が完了したら、それらをボットの自動化ロジックを実装するスクリプトに追加します。以下の例はPython 3を使用しています。
次のコマンドを使用してPythonライブラリをインストールします:
ライブラリのインストールpip3 install discordpip3 install requestspip3 install urllib3==1.26.6ボットロジックの異なる部分に対応する次のスクリプトを同じディレクトリにコピーして保存します:
bot_config.pyBOT_TOKEN = ""OPEN_CLOUD_API_KEY = ""ROBLOX_WEBHOOK_SECRET = ""# スタートプレイスIDから# (ユニバースID, (データストア名、スコープ、エントリーキー)のリスト)への辞書# 標準データストアに保存されているユーザーデータはこれらのエントリーの下で削除されますSTANDARD_DATA_STORE_ENTRIES = {# スタートプレイスID111111111: (# ユニバースID222222222,[("StandardDataStore1", "Scope1", "Key1_{user_id}"),("StandardDataStore1", "Scope1", "Key2_{user_id}"),("StandardDataStore2", "Scope1", "Key3_{user_id}")]),33333333: (444444444,[("StandardDataStore3", "Scope1", "Key1_{user_id}")])}# スタートプレイスIDから# (ユニバースID, (データストア名、スコープ、エントリーキー)のリスト)への辞書# 順序付きデータストアに保存されているユーザーデータはこれらのエントリーの下で削除されますORDERED_DATA_STORE_ENTRIES = {111111111: (222222222,[("OrderedDataStore1", "Scope2", "Key4_{user_id}")])}data_stores_api.pyimport requestsimport bot_configfrom collections import defaultdict"""Data Stores Open Cloud APIを呼び出して、STANDARD_DATA_STORE_ENTRIESに構成されているuser_idのすべてのエントリーを削除します。成功した削除と削除の失敗のリストを返します。"""def delete_standard_data_stores(user_id, start_place_ids):successes = defaultdict(list)failures = defaultdict(list)for owned_start_place_id in bot_config.STANDARD_DATA_STORE_ENTRIES:if owned_start_place_id not in start_place_ids:continueuniverse_id, universe_entries = bot_config.STANDARD_DATA_STORE_ENTRIES[owned_start_place_id]for (data_store_name, scope, entry_key) in universe_entries:entry_key = entry_key.replace("{user_id}", user_id)response = requests.delete(f"https://apis.roblox.com/datastores/v1/universes/{universe_id}/standard-datastores/datastore/entries/entry",headers={"x-api-key": bot_config.OPEN_CLOUD_API_KEY},params={"datastoreName": data_store_name,"scope": scope,"entryKey": entry_key})if response.status_code in [200, 204]:successes[owned_start_place_id].append((data_store_name, scope, entry_key))else:failures[owned_start_place_id].append((data_store_name, scope, entry_key))return successes, failures"""Ordered Data Stores Open Cloud APIを呼び出して、ORDERED_DATA_STORE_ENTRIESに構成されているuser_idのすべてのエントリーを削除します。成功した削除と削除の失敗のリストを返します。"""def delete_ordered_data_stores(user_id, start_place_ids):successes = defaultdict(list)failures = defaultdict(list)for owned_start_place_id in bot_config.ORDERED_DATA_STORE_ENTRIES:if owned_start_place_id not in start_place_ids:continueuniverse_id, universe_entries = bot_config.ORDERED_DATA_STORE_ENTRIES[owned_start_place_id]for (data_store_name, scope, entry_key) in universe_entries:entry_key = entry_key.replace("{user_id}", user_id)response = requests.delete(f"https://apis.roblox.com/ordered-data-stores/v1/universes/{universe_id}/orderedDatastores/{data_store_name}/scopes/{scope}/entries/{entry_key}",headers={"x-api-key": bot_config.OPEN_CLOUD_API_KEY})if response.status_code in [200, 204, 404]:successes[owned_start_place_id].append((data_store_name, scope, entry_key))else:failures[owned_start_place_id].append((data_store_name, scope, entry_key))return successes, failuresmessage_parser.pyimport timeimport hmacimport hashlibimport reimport base64import bot_config"""受信したメッセージからRobloxの署名とタイムスタンプを解析します。フッターはウェブフック秘密が設定されている場合のみ設定されます。"""def parse_footer(message):if not message.embeds[0].footer or \not message.embeds[0].footer.text:return "", 0footer_match = re.match(r"Roblox-Signature: (.*), Timestamp: (.*)",message.embeds[0].footer.text)if not footer_match:return "", 0else:signature = footer_match.group(1)timestamp = int(footer_match.group(2))return signature, timestamp"""設定された秘密を用いてRobloxの署名を検証し、有効性を確認します。"""def validate_signature(message, signature, timestamp):if not message or not signature or not timestamp:return False# 300秒のウィンドウ内での再送信攻撃を防ぐrequest_timestamp_ms = timestamp * 1000window_time_ms = 300 * 1000oldest_timestamp_allowed = round(time.time() * 1000) - window_time_msif request_timestamp_ms < oldest_timestamp_allowed:return False# 署名を検証しますtimestamp_message = "{}.{}".format(timestamp, message.embeds[0].description)digest = hmac.new(bot_config.ROBLOX_WEBHOOK_SECRET.encode(),msg=timestamp_message.encode(),digestmod=hashlib.sha256).digest()validated_signature = base64.b64encode(digest).decode()if signature != validated_signature:return False# 有効な署名return True"""Discordで受信したウェブフックメッセージを解析します。ユーザーIDを抽出し、受信したタイムスタンプに基づいて再送信攻撃を防ぎ、設定された秘密でRobloxの署名を検証して有効性を確認します。"""def parse_message(message):# 受信したメッセージからユーザーIDとゲームIDを解析しますif len(message.embeds) != 1 or \not message.embeds[0].description:return "", []description_match = re.match(r"あなたはユーザーID: (.*)の削除要求に対する新しい通知を受け取りました。" +r"ゲーム\(s\)のID: (.*)のです。",message.embeds[0].description)if not description_match:return "", []user_id = description_match.group(1)start_place_ids = set(int(item.strip()) for item in description_match.group(2).split(","))signature, timestamp = parse_footer(message)if validate_signature(message, signature, timestamp):return user_id, start_place_idselse:return "", []discord_bot.pyimport discordimport bot_configimport data_stores_apiimport message_parserdef run():intents = discord.Intents.default()intents.message_content = Trueclient = discord.Client(intents=intents)@client.eventasync def on_ready():print(f"{client.user} が削除要求のメッセージをリスニングしています")"""Robloxからのウェブフックメッセージのハンドラ"""@client.eventasync def on_message(message):# メッセージを解析し、検証しますuser_id, start_place_ids = message_parser.parse_message(message)if not user_id or not start_place_ids:return# 標準データストアのユーザーデータを削除します[successes, failures] = data_stores_api.delete_standard_data_stores(user_id, start_place_ids)if successes:await message.reply(f"ユーザーID: {user_id}の標準データストアデータを削除しました、データ: {dict(successes)}")if failures:await message.reply(f"ユーザーID: {user_id}の標準データストアデータの削除に失敗しました、データ: {dict(failures)}")# 順序付きデータストアのユーザーデータを削除します[successes, failures] = data_stores_api.delete_ordered_data_stores(user_id, start_place_ids)if successes:await message.reply(f"ユーザーID: {user_id}の順序付きデータストアデータを削除しました、データ: {dict(successes)}")if failures:await message.reply(f"ユーザーID: {user_id}の順序付きデータストアデータの削除に失敗しました、データ: {dict(failures)}")client.run(bot_config.BOT_TOKEN)if __name__ == "__main__":run()bot_config.pyファイルのボットのメイン設定を次のようにします:
- BOT_TOKENをボットによって生成されたトークンに設定します。
- OPEN_CLOUD_API_KEYを作成したAPIキーに設定します。
- ROBLOX_WEBHOOK_SECRETをCreator Dashboardでウェブフックを構成した際に設定した秘密に設定します。
- STANDARD_DATA_STORE_ENTRIESおよびORDERED_DATA_STORE_ENTRIES辞書には、削除するデータストアを見つけるために:
- コピーしたスタートプレイスIDをキーとして追加します。
- ユニバースIDをタプル値の最初の要素として追加します。
- タプルの2番目の要素をデータストアの名前、スコープ、エントリーキー名、および関連するユーザーIDに置き換えます。異なるデータスキーマを使用している場合は、自分のデータスキーマに合わせて修正します。
次のコマンドを実行してボットを起動します:
Discordボットを実行python3 discord_bot.pyボットは現在、削除要求のためのRobloxのウェブフックをリスニングして検証し、関連するデータストアの削除のためにOpen Cloudエンドポイントを呼び出します。
テスト
テストメッセージを作成して実行し、カスタムプログラムが削除要求を適切に処理しPIIデータを削除できるか確認できます:
次のリクエスト本文でDiscordウェブフックサーバーにHTTP POSTリクエストを送信します:
例のリクエストcurl -X POST {serverUrl}-H 'Content-Type: application/json'-d '{"embeds":[{"title":"RightToErasureRequest","description":"あなたはユーザーID: {userIds}に対する削除要求の新しい通知を受け取りました。" +"ゲームのID: {gameIds}のです。","footer":{"icon_url":"https://create.roblox.com/dashboard/assets/webhooks/roblox_logo_metal.png","text":"Roblox-Signature: {robloxSignature}, Timestamp: {timestamp}"}}]}'ウェブフック秘密がある場合:
- ウェブフック秘密キーに対してHMAC-SHA256エンコードを適用して、Roblox-Signatureを生成します。
- 現在の時刻をUTCタイムスタンプ(秒単位)としてTimestampに設定します。
次の形式でdescriptionを組み立てます:
説明フィールド形式{Timestamp}. あなたはユーザーID: {userId}に対する削除要求の新しい通知を受け取りました。" +"ゲームのID: {gameIds}のです。例えば:
例の説明フィールド1683927229. あなたはユーザーID: 2425654247に対する削除要求の新しい通知を受け取りました。" +"ゲームのID: 10539205763, 13260950955のです。
あなたのプログラムは、メッセージが公式なRobloxのソースからのものであることを特定できるはずです。なぜなら、メッセージを秘密でエンコードしているからです。それから、あなたの要求に関連するPIIデータを削除するはずです。
{
"embeds": [
{
"title": "RightToErasureRequest",
"description": "あなたはユーザーID: 2425654247に対する削除要求の新しい通知を受け取りました。" +
"ゲームのID: 10539205763, 13260950955のです。",
"footer": {
"icon_url": "https://create.roblox.com/dashboard/assets/webhooks/roblox_logo_metal.png",
"text": "Roblox-Signature: UIe6GJ78MHCmU/zUKBYP3LV0lAqwWRFR6UEfPt1xBFw=, Timestamp: 1683927229"
}
}
]
}