전 세계 데이터 보호 및 개인 정보 규정은 개인에게 자신의 데이터를 관리할 권리를 부여하며, 여기에는 데이터를 삭제 요청할 권리(일반적으로 "삭제 권리" 또는 "삭제 요청"이라고 함)가 포함됩니다. 사용자 ID와 같은 **개인 식별 정보 (PII)**를 저장하는 경우, 사용자 요청을 수신한 후 이 정보를 영구 삭제하여 적용 가능한 개인 정보 보호 프레임워크를 준수해야 합니다. 추가 정보는 RTBF 및 창작자에서 확인할 수 있습니다.
요청을 수동으로 처리하는 대신, 웹후크를 설정하고 써드파티 메시징 애플리케이션 내에서 봇을 사용하여 프로세스를 자동화할 수 있습니다. PII 데이터를 저장하는 가장 일반적인 방법인 데이터 저장소에서 이 튜토리얼은 Discord 내에서 Open Cloud API for data stores를 사용하여 PII 데이터를 삭제하는 봇을 만드는 방법에 대한 예제를 제공합니다.
작업 흐름
이 튜토리얼을 완료한 후에는 사용자로부터의 삭제 요청 처리를 자동화하는 로컬에서 실행되는 맞춤 프로그램을 만들 수 있어야 합니다. 이 프로세스의 작업 흐름은 다음과 같습니다.
- Roblox 지원팀이 사용자로부터 삭제 요청을 수신합니다.
- Roblox 웹후크가 트리거되어 페이로드에 사용자 ID와 사용자가 참여한 게임의 시작 플레이스 ID 목록을 포함합니다.
- 봇이 이러한 웹후크 알림을 수신 대기하고, 그 진위를 확인한 후, Open Cloud API for data stores를 사용하여 데이터 저장소에 저장된 PII 데이터를 삭제합니다.
- 봇이 Discord의 웹후크 메시지에 삭제 상태로 응답합니다.

써드파티 통합으로 웹후크 구성
봇을 만들기 전에 써드파티 메시징 애플리케이션에서 웹후크 통합이 있는 서버를 설정합니다. 그런 다음 해당 서버를 사용하여 Creator Dashboard에서 웹후크를 구성합니다.
서버 설정
다음 단계에서는 Discord를 사용하여 서버를 설정하는 방법을 보여줍니다.
새로운 Discord 서버를 만듭니다. 프로세스에 익숙하지 않은 경우 Discord 지원를 참조하세요.
서버는 기본 채널로 #general 채널을 자동으로 생성합니다. #general 채널의 채널 편집 아이콘을 클릭합니다.
권한에서 채널을 비공식으로 설정합니다.
새 서버로 웹후크 통합을 생성하고 이름을 RTBF Hook으로 지정합니다. 프로세스에 익숙하지 않은 경우 Discord 지원을 참조하세요.
웹후크 URL을 복사하고 안전한 장소에 저장합니다. URL을 유출하면 신뢰할 수 없는 팀원이 액세스할 수 있어, 잘못된 메시지를 보내고 사용자 데이터를 삭제할 수 있습니다.
Roblox에서 웹후크 구성
써드파티 서버 URL을 획득한 후, 이를 사용하여 Creator Dashboard에서 웹후크를 구성합니다. 다음 설정을 수행해야 합니다:
- Discord 서버 URL을 웹후크 URL로 추가합니다.
- 사용자 정의 비밀을 포함합니다. 비밀은 구성을 완료하는 데 선택 사항이지만, 악의적인 행위자가 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"""사용자 ID에 대해 STANDARD_DATA_STORE_ENTRIES에 구성된 모든 항목을 삭제하기 위해 Data Stores Open Cloud API를 호출합니다.성공적인 삭제와 실패를 처리합니다."""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"""사용자 ID에 대해 ORDERED_DATA_STORE_ENTRIES에 구성된 모든 항목을 삭제하기 위해 Ordered Data Stores Open Cloud API를 호출합니다.성공적인 삭제와 실패를 처리합니다."""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"You have received a new notification for Right to Erasure for the User Id: (.*) in " +r"the game\(s\) with Ids: (.*)",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} is listening to Right to Erasure messages")"""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}에 대한 표준 데이터 저장소 데이터 삭제 완료: " +f"{dict(successes)}")if failures:await message.reply(f"사용자 ID: {user_id}에 대한 표준 데이터 저장소 데이터 삭제 실패: " +f"{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}에 대한 순서형 데이터 저장소 데이터 삭제 완료: " +f"{dict(successes)}")if failures:await message.reply(f"사용자 ID: {user_id}에 대한 순서형 데이터 저장소 데이터 삭제 실패: " +f"{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를 튜플 값의 첫 번째 요소로 추가합니다.
- 두 번째 요소를 데이터 저장소의 이름, 범위, 항목 키 이름 및 관련 사용자 ID로 교체합니다. 다른 데이터 스키마를 사용하는 경우, 해당 데이터 스키마에 맞게 조정합니다.
봇을 실행하기 위해 다음 명령을 실행합니다:
Discord 봇 실행python3 discord_bot.py봇은 이제 웹후크를 수신하고 삭제 요청을 확인하며, 해당 데이터 저장소를 삭제하기 위해 Open Cloud 엔드포인트를 호출합니다.
테스트
사용자의 삭제 요청을 적절하게 처리하고 PII 데이터를 삭제할 수 있는지 확인하기 위해 테스트 메시지를 생성하고 실행할 수 있습니다:
다음 요청 본문과 함께 Discord 웹후크 서버에 HTTP POST 요청을 보냅니다:
예제 요청curl -X POST {serverUrl}-H 'Content-Type: application/json'-d '{"embeds":[{"title":"RightToErasureRequest","description":"You have received a new notification for Right to Erasure for the User Id: {userIds} in the game(s) with Ids: {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를 생성합니다.
- Timestamp로서 현재 시간을 UTC 타임스탬프(초 단위)로 설정합니다.
description을 다음 형식으로 조합합니다:
설명 필드 형식{Timestamp}. You have received a new notification for Right to Erasure for the User Id: {userId} in the game(s) with Ids: {gameIds}`.예를 들어:
예제 설명 필드1683927229. You have received a new notification for Right to Erasure for the User Id: 2425654247 in the game(s) with Ids: 10539205763, 13260950955
귀하의 프로그램은 메시지가 공식 Roblox 출처에서 온 것임을 식별할 수 있어야 하며, 귀하가 메시지를 비밀로 인코딩하였기 때문입니다. 그러면 요청에 따라 PII 데이터가 삭제됩니다.
{
"embeds": [
{
"title": "RightToErasureRequest",
"description": "You have received a new notification for Right to Erasure for the User Id: 2425654247 in the game(s) with Ids: 10539205763, 13260950955",
"footer": {
"icon_url": "https://create.roblox.com/dashboard/assets/webhooks/roblox_logo_metal.png",
"text": "Roblox-Signature: UIe6GJ78MHCmU/zUKBYP3LV0lAqwWRFR6UEfPt1xBFw=, Timestamp: 1683927229"
}
}
]
}